mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 02:26:36 +08:00
[优化] Phase2
This commit is contained in:
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
你会学到:OpenFlare 的整体架构、Server、Agent、OpenResty 与管理端前端的职责边界,以及一次配置发布从管理端到节点生效的请求流。
|
你会学到:OpenFlare 的整体架构、Server、Agent、OpenResty 与管理端前端的职责边界,以及一次配置发布从管理端到节点生效的请求流。
|
||||||
|
|
||||||
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。
|
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。内网穿透场景中,Relay(frps 管理器)和 OpenFlared(frpc 管理器)扩展了数据面流量路径。
|
||||||
|
|
||||||
|
### 标准反代流量路径
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browser
|
Browser
|
||||||
@@ -24,14 +26,38 @@ OpenResty binary
|
|||||||
Origin
|
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、配置渲染、版本发布、数据存储与聚合查询 |
|
| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、数据存储与聚合查询 |
|
||||||
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
||||||
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
|
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
|
||||||
| Frontend | 管理网站配置、WAF、源站、证书、节点、版本、用户、设置与观测页面 |
|
| OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 |
|
||||||
|
| OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 |
|
||||||
|
| Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 |
|
||||||
|
|
||||||
## Server
|
## Server
|
||||||
|
|
||||||
@@ -97,6 +123,27 @@ Agent 执行 OpenResty 校验与 reload
|
|||||||
Agent 上报应用结果
|
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 升级流程**(可选,通过 `AgentWebsocketUpgradeEnabled` 选项控制):
|
||||||
|
|
||||||
当启用 WebSocket 升级时:
|
当启用 WebSocket 升级时:
|
||||||
@@ -126,6 +173,7 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
|
|||||||
* `origins`
|
* `origins`
|
||||||
* `config_versions`
|
* `config_versions`
|
||||||
* `nodes`
|
* `nodes`
|
||||||
|
* `tunnels`
|
||||||
* `auth_sources`
|
* `auth_sources`
|
||||||
* `external_accounts`
|
* `external_accounts`
|
||||||
* `node_system_profiles`
|
* `node_system_profiles`
|
||||||
@@ -152,6 +200,9 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
|
|||||||
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致;支持版本预览、历史查询与一键回滚 |
|
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致;支持版本预览、历史查询与一键回滚 |
|
||||||
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
|
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
|
||||||
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
|
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
|
||||||
|
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道的稳定性风险;frps HTTP Vhost 路由天然适配 |
|
||||||
|
| Relay/Client 独立二进制 | 职责分离,Relay 管理 frps,Client 管理 frpc,各自独立升级和部署 |
|
||||||
|
| Tunnel 与 Node 体系分离 | Tunnel 客户端在内网运行,与公网节点概念不同,使用独立的注册和认证体系 |
|
||||||
|
|
||||||
## 贡献者阅读建议
|
## 贡献者阅读建议
|
||||||
|
|
||||||
|
|||||||
+38
-2
@@ -31,12 +31,15 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
|||||||
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
|
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
|
||||||
| 管理端前端 | 基于 Next.js 的正式管理端 |
|
| 管理端前端 | 基于 Next.js 的正式管理端 |
|
||||||
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
|
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
|
||||||
|
| 内网穿透 | 通过 TunnelRelay 节点与 OpenFlared 客户端,将内网 HTTP 服务安全暴露到公网,复用 Agent 的 HTTPS/WAF 能力 |
|
||||||
|
|
||||||
默认工作方式:
|
默认工作方式:
|
||||||
|
|
||||||
* 所有节点消费同一份全局激活版本。
|
* 所有节点消费同一份全局激活版本。
|
||||||
* Server 保存配置与状态,不直接 SSH 管理节点。
|
* Server 保存配置与状态,不直接 SSH 管理节点。
|
||||||
* Agent 是节点侧唯一受控落地入口。
|
* Agent 是节点侧唯一受控落地入口。
|
||||||
|
* TunnelRelay 节点同时运行 Agent(OpenResty)和 Relay(frps),提供内网穿透中继。
|
||||||
|
* OpenFlared 客户端在内网运行,管理 frpc 进程连接 Relay,将流量转发到内网服务。
|
||||||
|
|
||||||
## 典型使用场景
|
## 典型使用场景
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
|||||||
| 快速回滚 | 重新激活旧版本,让 Agent 拉取并应用 |
|
| 快速回滚 | 重新激活旧版本,让 Agent 拉取并应用 |
|
||||||
| 证书托管 | 为不同域名绑定 TLS 证书 |
|
| 证书托管 | 为不同域名绑定 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` 主上游字段,也允许在同一规则内补充多个上游做负载均衡。
|
* 为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡。
|
||||||
* 上游统一渲染为带 keepalive 的 named `upstream`。
|
* 上游统一渲染为带 keepalive 的 named `upstream`。
|
||||||
* 单上游可附带 base path 或 query 并在 `proxy_pass` 中追加。
|
* 单上游可附带 base path 或 query 并在 `proxy_pass` 中追加。
|
||||||
* 多上游限定为纯 `scheme://host[:port]`。
|
* 多上游限定为纯 `scheme://host[:port]`。
|
||||||
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头。
|
* `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 约束
|
## HTTPS 约束
|
||||||
|
|
||||||
|
|||||||
@@ -191,32 +191,27 @@ func agentWSReadTimeout() time.Duration {
|
|||||||
return timeout
|
return timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.AgentWSClient) {
|
func agentWSWriteTimeout() time.Duration {
|
||||||
pingTicker := time.NewTicker(30 * time.Second)
|
return 10 * time.Second
|
||||||
defer pingTicker.Stop()
|
}
|
||||||
|
|
||||||
|
func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case message := <-client.Messages():
|
case <-c.Request.Context().Done():
|
||||||
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
|
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 <-client.Done():
|
case <-client.Done():
|
||||||
return
|
return
|
||||||
case <-c.Request.Context().Done():
|
case message, ok := <-client.Messages():
|
||||||
client.Close()
|
if !ok {
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ func registeredModels() []any {
|
|||||||
&ProxyRoute{},
|
&ProxyRoute{},
|
||||||
&ConfigVersion{},
|
&ConfigVersion{},
|
||||||
&Node{},
|
&Node{},
|
||||||
|
&Tunnel{},
|
||||||
&NodeSystemProfile{},
|
&NodeSystemProfile{},
|
||||||
&ApplyLog{},
|
&ApplyLog{},
|
||||||
&NodeMetricSnapshot{},
|
&NodeMetricSnapshot{},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
|
|||||||
return validateDatabaseSchemaV13(db, backend)
|
return validateDatabaseSchemaV13(db, backend)
|
||||||
case 14:
|
case 14:
|
||||||
return validateDatabaseSchemaV14(db, backend)
|
return validateDatabaseSchemaV14(db, backend)
|
||||||
|
case 15:
|
||||||
|
return validateDatabaseSchemaV15(db, backend)
|
||||||
|
case 16:
|
||||||
|
return validateDatabaseSchemaV16(db, backend)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("database schema validation for v%d is not defined", version)
|
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
|
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 {
|
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||||
ctx := databaseSchemaMigrationContext{}
|
ctx := databaseSchemaMigrationContext{}
|
||||||
migrations := []databaseSchemaMigration{}
|
migrations := []databaseSchemaMigration{}
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ type Node struct {
|
|||||||
LastError string `json:"last_error" gorm:"type:text"`
|
LastError string `json:"last_error" gorm:"type:text"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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) {
|
func ListNodes() (nodes []*Node, err error) {
|
||||||
@@ -72,3 +84,8 @@ func (node *Node) Update() error {
|
|||||||
func (node *Node) Delete() error {
|
func (node *Node) Delete() error {
|
||||||
return DB.Delete(node).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
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ type ProxyRoute struct {
|
|||||||
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
|
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
|
||||||
BasicAuthPassword string `json:"basic_auth_password" 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"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -87,6 +91,10 @@ func (route *ProxyRoute) Update() error {
|
|||||||
"basic_auth_username": route.BasicAuthUsername,
|
"basic_auth_username": route.BasicAuthUsername,
|
||||||
"basic_auth_password": route.BasicAuthPassword,
|
"basic_auth_password": route.BasicAuthPassword,
|
||||||
"remark": route.Remark,
|
"remark": route.Remark,
|
||||||
|
"upstream_type": route.UpstreamType,
|
||||||
|
"tunnel_id": route.TunnelID,
|
||||||
|
"tunnel_target_addr": route.TunnelTargetAddr,
|
||||||
|
"tunnel_target_protocol": route.TunnelTargetProtocol,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -191,6 +191,16 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
applyLogRoute.GET("/", controller.GetApplyLogs)
|
applyLogRoute.GET("/", controller.GetApplyLogs)
|
||||||
applyLogRoute.POST("/cleanup", controller.CleanupApplyLogs)
|
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 := apiRouter.Group("/access-logs")
|
||||||
accessLogRoute.Use(middleware.AdminAuth())
|
accessLogRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
@@ -217,5 +227,19 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,16 @@ type NodeView struct {
|
|||||||
LatestApplyAt *time.Time `json:"latest_apply_at"`
|
LatestApplyAt *time.Time `json:"latest_apply_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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) {
|
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatResponse, error) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,11 +21,6 @@ type AgentWSInboundMessage struct {
|
|||||||
Payload json.RawMessage `json:"payload,omitempty"`
|
Payload json.RawMessage `json:"payload,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentWSOutboundMessage struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Payload any `json:"payload,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentWSBroadcastResult struct {
|
type AgentWSBroadcastResult struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Checksum string `json:"checksum"`
|
Checksum string `json:"checksum"`
|
||||||
@@ -35,133 +29,29 @@ type AgentWSBroadcastResult struct {
|
|||||||
FailedNodes []string `json:"failed_nodes"`
|
FailedNodes []string `json:"failed_nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentWSClient struct {
|
var DefaultAgentWSHub = NewWSHub("agent")
|
||||||
nodeID string
|
|
||||||
send chan AgentWSOutboundMessage
|
func RegisterAgentWSClient(nodeID string) *WSClient {
|
||||||
done chan struct{}
|
return DefaultAgentWSHub.Register(nodeID)
|
||||||
once sync.Once
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *AgentWSClient) NodeID() string {
|
func UnregisterAgentWSClient(client *WSClient) {
|
||||||
if client == nil {
|
DefaultAgentWSHub.Unregister(client)
|
||||||
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 DisconnectAgentWSClient(nodeID string) {
|
func DisconnectAgentWSClient(nodeID string) {
|
||||||
defaultAgentWSHub.mu.Lock()
|
DefaultAgentWSHub.Disconnect(nodeID)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsAgentWSConnected(nodeID string) bool {
|
func IsAgentWSConnected(nodeID string) bool {
|
||||||
defaultAgentWSHub.mu.RLock()
|
return DefaultAgentWSHub.IsConnected(nodeID)
|
||||||
client := defaultAgentWSHub.clients[nodeID]
|
|
||||||
defaultAgentWSHub.mu.RUnlock()
|
|
||||||
if client == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-client.done:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SendAgentWSSettings(nodeID string, settings *AgentSettings) bool {
|
func SendAgentWSSettings(nodeID string, settings *AgentSettings) bool {
|
||||||
if settings == nil {
|
if settings == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
|
||||||
Type: AgentWSMessageTypeSettings,
|
Type: AgentWSMessageTypeSettings,
|
||||||
Payload: settings,
|
Payload: settings,
|
||||||
})
|
})
|
||||||
@@ -171,7 +61,7 @@ func SendAgentWSActiveConfig(nodeID string, activeConfig *ActiveConfigMeta) bool
|
|||||||
if activeConfig == nil {
|
if activeConfig == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
|
||||||
Type: AgentWSMessageTypeActiveConfig,
|
Type: AgentWSMessageTypeActiveConfig,
|
||||||
Payload: activeConfig,
|
Payload: activeConfig,
|
||||||
})
|
})
|
||||||
@@ -181,60 +71,37 @@ func SendAgentWSForceSyncConfig(nodeID string, activeConfig *ActiveConfigMeta) b
|
|||||||
if activeConfig == nil {
|
if activeConfig == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
|
||||||
Type: AgentWSMessageTypeForceSyncConfig,
|
Type: AgentWSMessageTypeForceSyncConfig,
|
||||||
Payload: activeConfig,
|
Payload: activeConfig,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SendAgentWSPong(nodeID string) bool {
|
func SendAgentWSPong(nodeID string) bool {
|
||||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
|
||||||
Type: AgentWSMessageTypePong,
|
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 {
|
func BroadcastAgentWSActiveConfig(activeConfig *ActiveConfigMeta) AgentWSBroadcastResult {
|
||||||
result := AgentWSBroadcastResult{}
|
|
||||||
if activeConfig == nil {
|
if activeConfig == nil {
|
||||||
slog.Debug("agent ws broadcast skipped because active config is 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()
|
res := DefaultAgentWSHub.Broadcast(WSMessage{
|
||||||
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{
|
|
||||||
Type: AgentWSMessageTypeActiveConfig,
|
Type: AgentWSMessageTypeActiveConfig,
|
||||||
Payload: activeConfig,
|
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",
|
slog.Debug("agent ws broadcast active config",
|
||||||
"version", result.Version,
|
"version", result.Version,
|
||||||
"checksum", result.Checksum,
|
"checksum", result.Checksum,
|
||||||
|
|||||||
@@ -340,10 +340,12 @@ func PublishConfigVersion(createdBy string, force bool) (*ReleaseResult, error)
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
BroadcastAgentWSActiveConfig(&ActiveConfigMeta{
|
activeConfig := &ActiveConfigMeta{
|
||||||
Version: record.Version,
|
Version: record.Version,
|
||||||
Checksum: record.Checksum,
|
Checksum: record.Checksum,
|
||||||
})
|
}
|
||||||
|
BroadcastAgentWSActiveConfig(activeConfig)
|
||||||
|
BroadcastFlaredWSActiveConfig(activeConfig)
|
||||||
return &ReleaseResult{
|
return &ReleaseResult{
|
||||||
Version: record,
|
Version: record,
|
||||||
Routes: bundle.Routes,
|
Routes: bundle.Routes,
|
||||||
|
|||||||
@@ -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;") {
|
if !strings.Contains(result.Version.MainConfig, "error_log __OPENFLARE_ERROR_LOG__ warn;") {
|
||||||
t.Fatal("expected main config to include managed error log placeholder")
|
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")
|
t.Fatal("expected main config to include managed openresty lua log hook")
|
||||||
}
|
}
|
||||||
if !strings.Contains(result.Version.MainConfig, "listen __OPENFLARE_OBSERVABILITY_LISTEN__;") {
|
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__;") {
|
if !strings.Contains(preview.MainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") {
|
||||||
t.Fatal("expected preview main config to include managed route config placeholder")
|
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")
|
t.Fatal("expected preview main config to include managed openresty lua log hook")
|
||||||
}
|
}
|
||||||
if !strings.Contains(preview.RenderedConfig, `proxy_set_header X-Release "candidate";`) {
|
if !strings.Contains(preview.RenderedConfig, `proxy_set_header X-Release "candidate";`) {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ type NodeInput struct {
|
|||||||
GeoLatitude *float64 `json:"geo_latitude"`
|
GeoLatitude *float64 `json:"geo_latitude"`
|
||||||
GeoLongitude *float64 `json:"geo_longitude"`
|
GeoLongitude *float64 `json:"geo_longitude"`
|
||||||
GeoManualOverride bool `json:"geo_manual_override"`
|
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 {
|
type NodeAgentUpdateInput struct {
|
||||||
@@ -73,6 +80,7 @@ func CreateNode(input NodeInput) (*NodeView, error) {
|
|||||||
NginxVersion: "",
|
NginxVersion: "",
|
||||||
Status: NodeStatusPending,
|
Status: NodeStatusPending,
|
||||||
AutoUpdateEnabled: input.AutoUpdateEnabled,
|
AutoUpdateEnabled: input.AutoUpdateEnabled,
|
||||||
|
NodeType: normalizeNodeType(input.NodeType),
|
||||||
}
|
}
|
||||||
node.NodeID, err = newServerNodeID()
|
node.NodeID, err = newServerNodeID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,6 +90,17 @@ func CreateNode(input NodeInput) (*NodeView, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if !node.GeoManualOverride {
|
||||||
applyGeoInfoFromIP(node, node.IP)
|
applyGeoInfoFromIP(node, node.IP)
|
||||||
}
|
}
|
||||||
@@ -114,6 +133,17 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
|
|||||||
node.GeoLongitude = geoLongitude
|
node.GeoLongitude = geoLongitude
|
||||||
node.GeoManualOverride = geoManualOverride
|
node.GeoManualOverride = geoManualOverride
|
||||||
node.AutoUpdateEnabled = input.AutoUpdateEnabled
|
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 {
|
if !node.GeoManualOverride {
|
||||||
applyGeoInfoFromIP(node, strings.TrimSpace(node.IP))
|
applyGeoInfoFromIP(node, strings.TrimSpace(node.IP))
|
||||||
}
|
}
|
||||||
@@ -313,6 +343,18 @@ func buildNodeView(node *model.Node) *NodeView {
|
|||||||
if view.UpdateChannel == "" {
|
if view.UpdateChannel == "" {
|
||||||
view.UpdateChannel = ReleaseChannelStable.String()
|
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
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,3 +629,19 @@ func newServerNodeID() (string, error) {
|
|||||||
}
|
}
|
||||||
return "node-" + token, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ type ProxyRouteInput struct {
|
|||||||
BasicAuthUsername string `json:"basic_auth_username"`
|
BasicAuthUsername string `json:"basic_auth_username"`
|
||||||
BasicAuthPassword string `json:"basic_auth_password"`
|
BasicAuthPassword string `json:"basic_auth_password"`
|
||||||
Remark string `json:"remark"`
|
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 {
|
type ProxyRouteView struct {
|
||||||
@@ -96,6 +100,10 @@ type ProxyRouteView struct {
|
|||||||
BasicAuthUsername string `json:"basic_auth_username"`
|
BasicAuthUsername string `json:"basic_auth_username"`
|
||||||
BasicAuthPassword string `json:"basic_auth_password"`
|
BasicAuthPassword string `json:"basic_auth_password"`
|
||||||
Remark string `json:"remark"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -164,16 +172,27 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
|||||||
domain := domains[0]
|
domain := domains[0]
|
||||||
siteName := normalizeProxyRouteSiteNameInput(route, input.SiteName, domain)
|
siteName := normalizeProxyRouteSiteNameInput(route, input.SiteName, domain)
|
||||||
|
|
||||||
originURL, originID, err := resolveProxyRoutePrimaryOrigin(input)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
upstreams, err = normalizeUpstreams(originURL, input.Upstreams)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
originHost := strings.TrimSpace(input.OriginHost)
|
originHost := strings.TrimSpace(input.OriginHost)
|
||||||
remark := strings.TrimSpace(input.Remark)
|
remark := strings.TrimSpace(input.Remark)
|
||||||
upstreams, err := normalizeUpstreams(originURL, input.Upstreams)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cachePolicy := strings.TrimSpace(input.CachePolicy)
|
cachePolicy := strings.TrimSpace(input.CachePolicy)
|
||||||
cacheRules, err := normalizeCacheRules(input.CacheEnabled, cachePolicy, input.CacheRules)
|
cacheRules, err := normalizeCacheRules(input.CacheEnabled, cachePolicy, input.CacheRules)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -306,6 +325,16 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
|||||||
route.BasicAuthUsername = input.BasicAuthUsername
|
route.BasicAuthUsername = input.BasicAuthUsername
|
||||||
route.BasicAuthPassword = input.BasicAuthPassword
|
route.BasicAuthPassword = input.BasicAuthPassword
|
||||||
route.Remark = remark
|
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
|
return route, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +420,10 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
|
|||||||
BasicAuthUsername: route.BasicAuthUsername,
|
BasicAuthUsername: route.BasicAuthUsername,
|
||||||
BasicAuthPassword: route.BasicAuthPassword,
|
BasicAuthPassword: route.BasicAuthPassword,
|
||||||
Remark: route.Remark,
|
Remark: route.Remark,
|
||||||
|
UpstreamType: route.UpstreamType,
|
||||||
|
TunnelID: route.TunnelID,
|
||||||
|
TunnelTargetAddr: route.TunnelTargetAddr,
|
||||||
|
TunnelTargetProtocol: route.TunnelTargetProtocol,
|
||||||
CreatedAt: route.CreatedAt,
|
CreatedAt: route.CreatedAt,
|
||||||
UpdatedAt: route.UpdatedAt,
|
UpdatedAt: route.UpdatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -1208,3 +1241,21 @@ func decodeStoredPoWConfig(enabled bool, raw string) (*ProxyRoutePoWConfig, erro
|
|||||||
}
|
}
|
||||||
return &cfg, nil
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user