[优化] go 引用调整

This commit is contained in:
ryan
2026-06-06 10:26:20 +08:00
parent ee1110b752
commit 3cfefb4367
552 changed files with 1642 additions and 2185 deletions
+196
View File
@@ -0,0 +1,196 @@
package controller
import (
"strconv"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
// GetAccessLogs godoc
// @Summary List access logs
// @Tags AccessLogs
// @Produce json
// @Security OpenFlareTokenAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param path query string false "Path"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ [get]
func GetAccessLogs(c *gin.Context) {
logs, err := service.ListAccessLogs(readAccessLogQuery(c))
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, logs)
}
// GetFoldedAccessLogs godoc
// @Summary List folded access logs
// @Tags AccessLogs
// @Produce json
// @Security OpenFlareTokenAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param path query string false "Path"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Param fold_minutes query int false "Fold minutes"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/folds [get]
func GetFoldedAccessLogs(c *gin.Context) {
query := readAccessLogQuery(c)
query.FoldMinutes = readQueryInt(c, "fold_minutes")
logs, err := service.ListFoldedAccessLogs(query)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, logs)
}
// GetFoldedAccessLogIPs godoc
// @Summary List folded access log IP summaries
// @Tags AccessLogs
// @Produce json
// @Security OpenFlareTokenAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param path query string false "Path"
// @Param bucket_started_at query string true "Bucket started at"
// @Param fold_minutes query int true "Fold minutes"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/folds/ip-summary [get]
func GetFoldedAccessLogIPs(c *gin.Context) {
result, err := service.ListFoldedAccessLogIPs(service.FoldedAccessLogIPQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Path: c.Query("path"),
BucketStartedAt: c.Query("bucket_started_at"),
FoldMinutes: readQueryInt(c, "fold_minutes"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
// GetAccessLogIPSummaries godoc
// @Summary List access log IP summaries
// @Tags AccessLogs
// @Produce json
// @Security OpenFlareTokenAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string false "Remote address"
// @Param host query string false "Host"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Param sort_by query string false "Sort by"
// @Param sort_order query string false "Sort order"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ip-summary [get]
func GetAccessLogIPSummaries(c *gin.Context) {
result, err := service.ListAccessLogIPSummaries(service.AccessLogIPSummaryQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
// GetAccessLogIPTrend godoc
// @Summary Get access log IP trend
// @Tags AccessLogs
// @Produce json
// @Security OpenFlareTokenAuth
// @Param node_id query string false "Node ID"
// @Param remote_addr query string true "Remote address"
// @Param host query string false "Host"
// @Param hours query int false "Hours"
// @Param bucket_minutes query int false "Bucket minutes"
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ip-summary/trend [get]
func GetAccessLogIPTrend(c *gin.Context) {
result, err := service.GetAccessLogIPTrend(service.AccessLogIPTrendQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Hours: readQueryInt(c, "hours"),
BucketMinutes: readQueryInt(c, "bucket_minutes"),
})
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
// CleanupAccessLogs godoc
// @Summary Cleanup access logs by retention days
// @Tags AccessLogs
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/cleanup [post]
func CleanupAccessLogs(c *gin.Context) {
var input service.AccessLogCleanupInput
if !bind.JSON(c, &input) {
return
}
result, err := service.CleanupAccessLogs(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
func readAccessLogQuery(c *gin.Context) service.AccessLogQuery {
return service.AccessLogQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Path: c.Query("path"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
}
}
func readQueryInt(c *gin.Context, key string) int {
value, _ := strconv.Atoi(c.DefaultQuery(key, "0"))
return value
}
@@ -0,0 +1,24 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/gin-gonic/gin"
)
// GetDefaultAcmeAccount godoc
// @Summary Get default ACME account
// @Tags AcmeAccounts
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/acme-accounts/default [get]
func GetDefaultAcmeAccount(c *gin.Context) {
account, err := model.GetDefaultAcmeAccount()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, account)
}
+363
View File
@@ -0,0 +1,363 @@
package controller
import (
"encoding/json"
"log/slog"
"net"
"strconv"
"time"
"github.com/rain-kl/openflare/openflare-server/common"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
"golang.org/x/net/websocket"
)
// AgentRegister godoc
// @Summary Register or discover agent node
// @Tags Agent
// @Accept json
// @Produce json
// @Security AccessTokenAuth
// @Param payload body service.AgentNodePayload true "Agent node payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/agent/nodes/register [post]
func AgentRegister(c *gin.Context) {
var payload service.AgentNodePayload
if !bind.JSON(c, &payload) {
return
}
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
var (
result *service.AgentRegistrationResponse
err error
)
if authNode, ok := c.Get("agent_node"); ok {
result, err = service.RegisterNodeWithAccessToken(authNode.(*model.Node), payload)
} else {
result, err = service.RegisterNodeWithDiscovery(payload)
}
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
// AgentHeartbeat godoc
// @Summary Report agent heartbeat
// @Tags Agent
// @Accept json
// @Produce json
// @Security AccessTokenAuth
// @Param payload body service.AgentNodePayload true "Agent heartbeat payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/agent/nodes/heartbeat [post]
func AgentHeartbeat(c *gin.Context) {
var payload service.AgentNodePayload
if !bind.JSON(c, &payload) {
return
}
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
authNode, ok := c.Get("agent_node")
if !ok {
response.RespondUnauthorized(c, "鏃犳潈杩涜姝ゆ搷浣滐紝Agent Token 鏃犳晥")
return
}
node, err := service.HeartbeatNode(authNode.(*model.Node), payload)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessWithExtras(c, node.Node, gin.H{
"agent_settings": node.AgentSettings,
"active_config": node.ActiveConfig,
"waf_ip_groups": node.WAFIPGroups,
})
}
// AgentSyncWAFIPGroups godoc
// @Summary Sync WAF IP groups for agent
// @Tags Agent
// @Accept json
// @Produce json
// @Security AccessTokenAuth
// @Param payload body service.AgentWAFIPGroupSyncInput true "WAF IP group sync payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/agent/waf/ip-groups/sync [post]
func AgentSyncWAFIPGroups(c *gin.Context) {
var input service.AgentWAFIPGroupSyncInput
if !bind.JSON(c, &input) {
return
}
result, err := service.SyncWAFIPGroupsForAgent(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
// AgentGetActiveConfig godoc
// @Summary Get active config for agent
// @Tags Agent
// @Produce json
// @Security AccessTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/agent/config-versions/active [get]
func AgentGetActiveConfig(c *gin.Context) {
authNode, ok := c.Get("agent_node")
if !ok {
response.RespondUnauthorized(c, "Node object missing from context")
return
}
node := authNode.(*model.Node)
if node.NodeType == "tunnel_client" {
config, err := service.GetFlaredTunnelConfig(node)
if err != nil {
response.RespondFailure(c, "无法生成隧道配置: "+err.Error())
return
}
response.RespondSuccess(c, config)
return
}
config, err := service.GetActiveConfigForAgent()
if err != nil {
response.RespondFailure(c, "当前没有激活版本")
return
}
response.RespondSuccess(c, config)
}
// AgentReportApplyLog godoc
// @Summary Report agent apply result
// @Tags Agent
// @Accept json
// @Produce json
// @Security AccessTokenAuth
// @Param payload body service.ApplyLogPayload true "Apply log payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/agent/apply-logs [post]
func AgentReportApplyLog(c *gin.Context) {
var payload service.ApplyLogPayload
if !bind.JSON(c, &payload) {
return
}
if authNode, ok := c.Get("agent_node"); ok {
payload.NodeID = authNode.(*model.Node).NodeID
}
log, err := service.ReportApplyLog(payload)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, log)
}
// AgentWebSocket godoc
// @Summary Upgrade agent connection to websocket
// @Tags Agent
// @Security AccessTokenAuth
// @Router /api/agent/ws [get]
func AgentWebSocket(c *gin.Context) {
authNode, ok := c.Get("agent_node")
if !ok {
response.RespondUnauthorized(c, "无权进行此操作,Agent Token 无效")
return
}
node := authNode.(*model.Node)
slog.Debug("agent ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
websocket.Handler(func(conn *websocket.Conn) {
client := service.RegisterAgentWSClient(node.NodeID)
defer service.UnregisterAgentWSClient(client)
defer func() {
_ = conn.Close()
slog.Debug("agent ws connection closed", "node_id", node.NodeID)
}()
slog.Debug("agent ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
go func() {
<-client.Done()
_ = conn.Close()
}()
go streamAgentWSMessages(c, conn, client)
for {
var message service.AgentWSInboundMessage
_ = 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("agent ws receive timeout waiting for status or pong", "node_id", node.NodeID, "timeout", agentWSReadTimeout())
return
}
slog.Debug("agent ws receive failed", "node_id", node.NodeID, "error", err)
return
}
slog.Debug("agent ws message received", "node_id", node.NodeID, "type", message.Type)
switch message.Type {
case service.AgentWSMessageTypeStatus:
handleAgentWSStatus(c, node, message)
case service.AgentWSMessageTypePing:
if !service.SendAgentWSPong(node.NodeID) {
slog.Debug("agent ws pong enqueue failed", "node_id", node.NodeID)
}
case service.AgentWSMessageTypePong:
slog.Debug("agent ws pong received", "node_id", node.NodeID)
default:
slog.Debug("agent ws unsupported message type", "node_id", node.NodeID, "type", message.Type)
}
}
}).ServeHTTP(c.Writer, c.Request)
}
func agentWSReadTimeout() time.Duration {
timeout := time.Duration(common.AgentHeartbeatInterval) * time.Millisecond * 3
if timeout < 30*time.Second {
return 30 * time.Second
}
return timeout
}
func agentWSWriteTimeout() time.Duration {
return 10 * time.Second
}
func streamAgentWSMessages(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("agent ws send failed", "node_id", client.ID(), "error", err)
return
}
}
}
}
func handleAgentWSStatus(c *gin.Context, node *model.Node, message service.AgentWSInboundMessage) {
var payload service.AgentNodePayload
if err := json.Unmarshal(message.Payload, &payload); err != nil {
slog.Debug("agent ws status payload decode failed", "node_id", node.NodeID, "error", err)
return
}
freshNode, err := model.GetNodeByNodeID(node.NodeID)
if err != nil {
slog.Debug("agent ws status reload node failed", "node_id", node.NodeID, "error", err)
return
}
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
res, err := service.HeartbeatNode(freshNode, payload)
if err != nil {
slog.Debug("agent ws status handling failed", "node_id", node.NodeID, "error", err)
return
}
settingsSent := service.SendAgentWSSettings(node.NodeID, res.AgentSettings)
activeConfigSent := false
if res.ActiveConfig != nil {
activeConfigSent = service.SendAgentWSActiveConfig(node.NodeID, res.ActiveConfig)
}
wafIPGroupsSent := false
if len(res.WAFIPGroups) > 0 {
wafIPGroupsSent = service.SendAgentWSWAFIPGroups(node.NodeID, res.WAFIPGroups)
}
slog.Debug("agent ws status processed",
"node_id", node.NodeID,
"current_version", payload.CurrentVersion,
"openresty_status", payload.OpenrestyStatus,
"settings_sent", settingsSent,
"active_config_sent", activeConfigSent,
"waf_ip_groups_sent", wafIPGroupsSent,
)
}
// GetNodes godoc
// @Summary List nodes
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/nodes/ [get]
func GetNodes(c *gin.Context) {
nodes, err := service.ListNodeViews()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, nodes)
}
// GetApplyLogs godoc
// @Summary List apply logs
// @Tags ApplyLogs
// @Produce json
// @Security OpenFlareTokenAuth
// @Param node_id query string false "Node ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/apply-logs/ [get]
func GetApplyLogs(c *gin.Context) {
logs, err := service.ListApplyLogsPage(service.ApplyLogListQuery{
NodeID: c.Query("node_id"),
PageNo: readIntQueryFallback(c, "pageNo", "page_no"),
PageSize: readIntQueryFallback(c, "pageSize", "page_size"),
})
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, logs)
}
// CleanupApplyLogs godoc
// @Summary Cleanup apply logs
// @Tags ApplyLogs
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/apply-logs/cleanup [post]
func CleanupApplyLogs(c *gin.Context) {
var input service.ApplyLogCleanupInput
if !bind.JSON(c, &input) {
return
}
result, err := service.CleanupApplyLogs(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
func readIntQueryFallback(c *gin.Context, primary string, secondary string) int {
value := c.Query(primary)
if value == "" {
value = c.Query(secondary)
}
parsed, _ := strconv.Atoi(value)
return parsed
}
+358
View File
@@ -0,0 +1,358 @@
package controller
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/rain-kl/openflare/openflare-server/common"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const pendingExternalAccountSessionKey = "pending_external_account"
type authSourceTogglePayload struct {
IsActive bool `json:"is_active"`
}
type authSourcePayload struct {
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
Scopes string `json:"scopes"`
IconURL string `json:"icon_url"`
}
func (payload authSourcePayload) toModel() model.AuthSource {
return model.AuthSource{
Name: payload.Name,
Type: payload.Type,
DisplayName: payload.DisplayName,
IsActive: payload.IsActive,
ClientID: payload.ClientID,
ClientSecret: payload.ClientSecret,
OpenIDDiscoveryURL: payload.OpenIDDiscoveryURL,
Scopes: payload.Scopes,
IconURL: payload.IconURL,
}
}
func ListAuthSources(c *gin.Context) {
sources, err := model.GetAuthSources()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, sources)
}
func CreateAuthSource(c *gin.Context) {
var payload authSourcePayload
if err := bind.DecodeJSONBody(c.Request.Body, &payload); err != nil {
response.RespondBadRequest(c, "无效的参数")
return
}
source := payload.toModel()
if err := model.CreateAuthSource(&source); err != nil {
response.RespondFailure(c, err.Error())
return
}
source.Sanitize()
response.RespondSuccess(c, source)
}
func UpdateAuthSource(c *gin.Context) {
id, err := parseAuthSourceID(c)
if err != nil {
response.RespondBadRequest(c, err.Error())
return
}
var payload authSourcePayload
if err := bind.DecodeJSONBody(c.Request.Body, &payload); err != nil {
response.RespondBadRequest(c, "无效的参数")
return
}
source := payload.toModel()
source.ID = id
keepSecret := strings.TrimSpace(source.ClientSecret) == ""
if err := model.UpdateAuthSource(&source, keepSecret); err != nil {
response.RespondFailure(c, err.Error())
return
}
updated, err := model.GetAuthSourceByID(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
updated.Sanitize()
response.RespondSuccess(c, updated)
}
func DeleteAuthSource(c *gin.Context) {
id, err := parseAuthSourceID(c)
if err != nil {
response.RespondBadRequest(c, err.Error())
return
}
if err := model.DeleteAuthSource(id); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func ToggleAuthSource(c *gin.Context) {
id, err := parseAuthSourceID(c)
if err != nil {
response.RespondBadRequest(c, err.Error())
return
}
var payload authSourceTogglePayload
if err := bind.DecodeJSONBody(c.Request.Body, &payload); err != nil {
response.RespondBadRequest(c, "无效的参数")
return
}
if err := model.ToggleAuthSource(id, payload.IsActive); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func OAuthAuthorize(c *gin.Context) {
source, err := getAuthSourceFromRoute(c)
if err != nil {
response.RespondBadRequest(c, err.Error())
return
}
if !source.IsActive {
response.RespondFailure(c, "认证源未启用")
return
}
if err := source.Validate(); err != nil {
response.RespondFailure(c, err.Error())
return
}
state, err := service.GenerateOAuthState()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
session := sessions.Default(c)
session.Set(oauthStateSessionKey(source.ID), state)
if err := session.Save(); err != nil {
response.RespondFailure(c, "无法保存授权状态,请重试")
return
}
redirectURL := oauthFrontendCallbackURL(c, source.ID)
authorizeURL, err := service.BuildAuthorizeURL(c.Request.Context(), source, redirectURL, state)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, gin.H{"authorize_url": authorizeURL})
}
func OAuthCallback(c *gin.Context) {
source, err := getAuthSourceFromRoute(c)
if err != nil {
response.RespondBadRequest(c, err.Error())
return
}
if !source.IsActive {
response.RespondFailure(c, "认证源未启用")
return
}
session := sessions.Default(c)
expectedState, _ := session.Get(oauthStateSessionKey(source.ID)).(string)
state := c.Query("state")
if expectedState == "" || state == "" || state != expectedState {
response.RespondFailure(c, "授权状态无效,请重新登录")
return
}
session.Delete(oauthStateSessionKey(source.ID))
if err := session.Save(); err != nil {
response.RespondFailure(c, "无法更新授权状态,请重试")
return
}
if oauthError := c.Query("error"); oauthError != "" {
description := c.Query("error_description")
if description == "" {
description = oauthError
}
response.RespondFailure(c, description)
return
}
profile, err := service.ExchangeOAuthProfile(c.Request.Context(), source, c.Query("code"), oauthFrontendCallbackURL(c, source.ID))
if err != nil {
response.RespondFailure(c, err.Error())
return
}
var currentUserID *int
if currentUser := currentUserFromOpenFlareToken(c); currentUser != nil {
currentUserID = &currentUser.Id
}
result, pending, err := service.CompleteOAuthLogin(source, profile, currentUserID)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
if pending != nil {
raw, err := json.Marshal(pending)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
session.Set(pendingExternalAccountSessionKey, string(raw))
if err := session.Save(); err != nil {
response.RespondFailure(c, "无法保存待绑定账号,请重试")
return
}
response.RespondSuccess(c, result)
return
}
if result.User != nil {
cleanUser, err := setLoginToken(result.User)
if err != nil {
response.RespondFailure(c, "无法保存会话信息,请重试")
return
}
result.User = cleanUser
}
response.RespondSuccess(c, result)
}
func LinkExistingOAuthAccount(c *gin.Context) {
session := sessions.Default(c)
raw, _ := session.Get(pendingExternalAccountSessionKey).(string)
if raw == "" {
response.RespondFailure(c, "待绑定第三方账号已失效,请重新登录")
return
}
var pending service.PendingExternalAccount
if err := json.Unmarshal([]byte(raw), &pending); err != nil {
response.RespondFailure(c, "待绑定第三方账号无效,请重新登录")
return
}
var input service.LinkExistingRequest
if err := bind.DecodeJSONBody(c.Request.Body, &input); err != nil {
response.RespondBadRequest(c, "无效的参数")
return
}
user, err := service.LinkPendingExternalAccount(&pending, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
session.Delete(pendingExternalAccountSessionKey)
if err := session.Save(); err != nil {
response.RespondFailure(c, "无法更新会话信息,请重试")
return
}
cleanUser, err := setLoginToken(user)
if err != nil {
response.RespondFailure(c, "无法保存会话信息,请重试")
return
}
response.RespondSuccess(c, service.OAuthCallbackResult{Status: "linked", User: cleanUser})
}
func ListExternalAccounts(c *gin.Context) {
userID := c.GetInt("id")
accounts, err := model.ListExternalAccountsByUserID(userID)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, accounts)
}
func DeleteExternalAccount(c *gin.Context) {
rawID := strings.TrimSpace(c.Param("id"))
parsedID, err := strconv.ParseUint(rawID, 10, 64)
if err != nil || parsedID == 0 {
response.RespondBadRequest(c, "绑定记录 ID 无效")
return
}
if err := model.DeleteExternalAccountForUser(uint(parsedID), c.GetInt("id")); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func parseAuthSourceID(c *gin.Context) (uint, error) {
raw := c.Param("source_id")
if raw == "" {
raw = c.Param("id")
}
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil || parsed == 0 {
return 0, fmt.Errorf("认证源 ID 无效")
}
return uint(parsed), nil
}
func getAuthSourceFromRoute(c *gin.Context) (*model.AuthSource, error) {
raw := strings.TrimSpace(c.Param("source"))
if raw == "" {
raw = strings.TrimSpace(c.Param("source_id"))
}
if raw == "" {
raw = strings.TrimSpace(c.Param("id"))
}
if raw == "" {
return nil, fmt.Errorf("认证源不能为空")
}
if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil && parsed > 0 {
source, err := model.GetAuthSourceByID(uint(parsed))
if err != nil {
return nil, err
}
return source, nil
}
source, err := model.GetAuthSourceByName(raw)
if err != nil {
return nil, err
}
return source, nil
}
func oauthStateSessionKey(sourceID uint) string {
return fmt.Sprintf("oauth_state_%d", sourceID)
}
func oauthFrontendCallbackURL(c *gin.Context, sourceID uint) string {
base := strings.TrimRight(common.ServerAddress, "/")
if base == "" {
scheme := "http"
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := c.Request.Host
if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
base = scheme + "://" + host
}
source, err := model.GetAuthSourceByID(sourceID)
sourceName := strconv.FormatUint(uint64(sourceID), 10)
if err == nil && strings.TrimSpace(source.Name) != "" {
sourceName = source.Name
}
callback, _ := url.JoinPath(base, "oauth", sourceName)
return callback
}
+49
View File
@@ -0,0 +1,49 @@
package bind
import (
"encoding/json"
"errors"
"io"
"strconv"
"github.com/gin-gonic/gin"
"github.com/rain-kl/openflare/openflare-server/common/response"
)
// DecodeJSONBody decodes JSON reader to target
func DecodeJSONBody(body io.Reader, target any) error {
return json.NewDecoder(body).Decode(target)
}
// OptionalJSON decodes optional JSON body of reader to target, allowing EOF
func OptionalJSON(body io.Reader, target any) error {
if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
// IDParam parses "id" parameter from context path
func IDParam(c *gin.Context) (uint, bool) {
return IDParamByName(c, "id")
}
// IDParamByName parses target parameter from context path
func IDParamByName(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
response.RespondBadRequest(c, "")
return 0, false
}
return uint(id), true
}
// JSON binds JSON body of context request to target
func JSON(c *gin.Context, target any) bool {
if err := DecodeJSONBody(c.Request.Body, target); err != nil {
response.RespondBadRequest(c, "")
return false
}
return true
}
@@ -0,0 +1,165 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
// GetConfigVersions godoc
// @Summary List config versions
// @Tags ConfigVersions
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/config-versions/ [get]
func GetConfigVersions(c *gin.Context) {
versions, err := service.ListConfigVersions()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, versions)
}
// GetConfigVersion godoc
// @Summary Get config version detail
// @Tags ConfigVersions
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Version ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/config-versions/{id} [get]
func GetConfigVersion(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
version, err := service.GetConfigVersionDetail(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, version)
}
// GetActiveConfigVersion godoc
// @Summary Get active config version
// @Tags ConfigVersions
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/config-versions/active [get]
func GetActiveConfigVersion(c *gin.Context) {
version, err := service.GetActiveConfigVersion()
if err != nil {
response.RespondFailure(c, "当前没有激活版本")
return
}
response.RespondSuccess(c, version)
}
// PreviewConfigVersion godoc
// @Summary Preview config rendering
// @Tags ConfigVersions
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/config-versions/preview [get]
func PreviewConfigVersion(c *gin.Context) {
preview, err := service.PreviewConfigVersion()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, preview)
}
// DiffConfigVersion godoc
// @Summary Diff current draft against active version
// @Tags ConfigVersions
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/config-versions/diff [get]
func DiffConfigVersion(c *gin.Context) {
diff, err := service.DiffConfigVersion()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, diff)
}
// PublishConfigVersion godoc
// @Summary Publish a new config version
// @Tags ConfigVersions
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/config-versions/publish [post]
func PublishConfigVersion(c *gin.Context) {
username := c.GetString("username")
force := c.Query("force") == "true"
result, err := service.PublishConfigVersion(username, force)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result.Version)
}
// ActivateConfigVersion godoc
// @Summary Activate an existing config version
// @Tags ConfigVersions
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Version ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/config-versions/{id}/activate [post]
func ActivateConfigVersion(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
version, err := service.ActivateConfigVersion(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, version)
}
type CleanupConfigVersionRequest struct {
KeepCount int `json:"keep_count" binding:"required,min=3"`
}
// CleanupConfigVersions godoc
// @Summary Cleanup old config versions
// @Tags ConfigVersions
// @Produce json
// @Security OpenFlareTokenAuth
// @Param request body CleanupConfigVersionRequest true "Cleanup request"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/config-versions/cleanup [post]
func CleanupConfigVersions(c *gin.Context) {
var req CleanupConfigVersionRequest
if !bind.JSON(c, &req) {
return
}
deletedCount, err := service.CleanupConfigVersions(req.KeepCount)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessWithExtras(c, map[string]interface{}{"deleted_count": deletedCount}, gin.H{
"message": "清理成功",
})
}
+173
View File
@@ -0,0 +1,173 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
type dashboardOverviewPayload struct {
GeneratedAt any `json:"generated_at"`
Summary service.DashboardSummary `json:"summary"`
Traffic service.DashboardTraffic `json:"traffic"`
Capacity service.DashboardCapacity `json:"capacity"`
Distributions dashboardDistributionsPayload `json:"distributions"`
Trends dashboardTrendsPayload `json:"trends"`
Nodes [][]any `json:"nodes"`
}
type dashboardDistributionsPayload struct {
StatusCodes [][]any `json:"status_codes"`
TopDomains [][]any `json:"top_domains"`
SourceCountries [][]any `json:"source_countries"`
}
type dashboardTrendsPayload struct {
Traffic24h [][]any `json:"traffic_24h"`
Capacity24h [][]any `json:"capacity_24h"`
Network24h [][]any `json:"network_24h"`
DiskIO24h [][]any `json:"disk_io_24h"`
}
// GetDashboardOverview godoc
// @Summary Get dashboard overview
// @Tags Dashboard
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/dashboard/overview [get]
func GetDashboardOverview(c *gin.Context) {
view, err := service.GetDashboardOverview()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, compressDashboardOverview(view))
}
func compressDashboardOverview(view *service.DashboardOverviewView) *dashboardOverviewPayload {
if view == nil {
return &dashboardOverviewPayload{
Distributions: dashboardDistributionsPayload{
StatusCodes: [][]any{},
TopDomains: [][]any{},
SourceCountries: [][]any{},
},
Trends: dashboardTrendsPayload{
Traffic24h: [][]any{},
Capacity24h: [][]any{},
Network24h: [][]any{},
DiskIO24h: [][]any{},
},
Nodes: [][]any{},
}
}
return &dashboardOverviewPayload{
GeneratedAt: view.GeneratedAt,
Summary: view.Summary,
Traffic: view.Traffic,
Capacity: view.Capacity,
Distributions: dashboardDistributionsPayload{
StatusCodes: compressDistributionItems(view.Distributions.StatusCodes),
TopDomains: compressDistributionItems(view.Distributions.TopDomains),
SourceCountries: compressDistributionItems(view.Distributions.SourceCountries),
},
Trends: dashboardTrendsPayload{
Traffic24h: compressTrafficTrendPoints(view.Trends.Traffic24h),
Capacity24h: compressCapacityTrendPoints(view.Trends.Capacity24h),
Network24h: compressNetworkTrendPoints(view.Trends.Network24h),
DiskIO24h: compressDiskIOTrendPoints(view.Trends.DiskIO24h),
},
Nodes: compressDashboardNodes(view.Nodes),
}
}
func compressDistributionItems(items []service.DistributionItem) [][]any {
rows := make([][]any, 0, len(items))
for _, item := range items {
rows = append(rows, []any{item.Key, item.Value})
}
return rows
}
func compressTrafficTrendPoints(points []service.TrafficTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.RequestCount,
point.ErrorCount,
point.UniqueVisitorCount,
})
}
return rows
}
func compressCapacityTrendPoints(points []service.CapacityTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.AverageCPUUsagePercent,
point.AverageMemoryUsagePercent,
point.ReportedNodes,
})
}
return rows
}
func compressNetworkTrendPoints(points []service.NetworkTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.NetworkRxBytes,
point.NetworkTxBytes,
point.OpenrestyRxBytes,
point.OpenrestyTxBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDiskIOTrendPoints(points []service.DiskIOTrendPoint) [][]any {
rows := make([][]any, 0, len(points))
for _, point := range points {
rows = append(rows, []any{
point.BucketStartedAt,
point.DiskReadBytes,
point.DiskWriteBytes,
point.ReportedNodes,
})
}
return rows
}
func compressDashboardNodes(nodes []service.DashboardNodeHealth) [][]any {
rows := make([][]any, 0, len(nodes))
for _, node := range nodes {
rows = append(rows, []any{
node.ID,
node.NodeID,
node.Name,
node.GeoName,
node.GeoLatitude,
node.GeoLongitude,
node.Status,
node.OpenrestyStatus,
node.CurrentVersion,
node.LastSeenAt,
node.ActiveEventCount,
node.CPUUsagePercent,
node.MemoryUsagePercent,
node.StorageUsagePercent,
node.RequestCount,
node.ErrorCount,
node.UniqueVisitorCount,
})
}
return rows
}
+31
View File
@@ -0,0 +1,31 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
// CleanupDatabaseObservability godoc
// @Summary Cleanup observability tables
// @Tags Options
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/option/database/cleanup [post]
func CleanupDatabaseObservability(c *gin.Context) {
var input service.DatabaseCleanupInput
if err := bind.OptionalJSON(c.Request.Body, &input); err != nil {
response.RespondBadRequest(c, "")
return
}
result, err := service.CleanupDatabaseObservability(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
+135
View File
@@ -0,0 +1,135 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/gin-gonic/gin"
)
type DnsAccountInput struct {
Name string `json:"name"`
Type string `json:"type"`
Authorization string `json:"authorization"`
}
// GetDnsAccounts godoc
// @Summary List DNS accounts
// @Tags DnsAccounts
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/ [get]
func GetDnsAccounts(c *gin.Context) {
accounts, err := model.ListDnsAccounts()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, accounts)
}
// CreateDnsAccount godoc
// @Summary Create DNS account
// @Tags DnsAccounts
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param payload body DnsAccountInput true "DNS account payload"
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/ [post]
func CreateDnsAccount(c *gin.Context) {
var input DnsAccountInput
if !bind.JSON(c, &input) {
return
}
account := &model.DnsAccount{
Name: input.Name,
Type: input.Type,
Authorization: input.Authorization,
}
if err := account.Insert(); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, account)
}
// UpdateDnsAccount godoc
// @Summary Update DNS account
// @Tags DnsAccounts
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "DNS Account ID"
// @Param payload body DnsAccountInput true "DNS account payload"
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/{id}/update [post]
func UpdateDnsAccount(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input DnsAccountInput
if !bind.JSON(c, &input) {
return
}
account, err := model.GetDnsAccountByID(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
account.Name = input.Name
account.Type = input.Type
account.Authorization = input.Authorization
if err := account.Update(); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, account)
}
// DeleteDnsAccount godoc
// @Summary Delete DNS account
// @Tags DnsAccounts
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "DNS Account ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/{id}/delete [post]
func DeleteDnsAccount(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
account, err := model.GetDnsAccountByID(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
// Verify no cert uses this before deleting
var count int64
model.DB.Model(&model.TLSCertificate{}).Where("dns_account_id = ?", id).Count(&count)
if count > 0 {
response.RespondFailure(c, "该 DNS 账号已被证书使用,无法删除")
return
}
if err := account.Delete(); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, nil)
}
+176
View File
@@ -0,0 +1,176 @@
package controller
import (
"log/slog"
"net"
"time"
"github.com/rain-kl/openflare/openflare-server/common"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
"golang.org/x/net/websocket"
)
// FlaredHeartbeat godoc
// @Summary Report OpenFlared 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 !bind.JSON(c, &payload) {
return
}
authNode, ok := c.Get("flared_node")
if !ok {
response.RespondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
return
}
node := authNode.(*model.Node)
res, err := service.HeartbeatFlared(node, payload)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, res)
}
// FlaredGetActiveConfig godoc
// @Summary Get active tunnel config for OpenFlared
// @Tags Flared
// @Produce json
// @Security TunnelTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/flared/config/active [get]
func FlaredGetActiveConfig(c *gin.Context) {
authNode, ok := c.Get("flared_node")
if !ok {
response.RespondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
return
}
node := authNode.(*model.Node)
config, err := service.GetFlaredTunnelConfig(node)
if err != nil {
response.RespondFailure(c, "无法生成隧道配置: "+err.Error())
return
}
response.RespondSuccess(c, config)
}
// FlaredReportApplyLog godoc
// @Summary Report OpenFlared apply result
// @Tags Flared
// @Accept json
// @Produce json
// @Security TunnelTokenAuth
// @Param payload body service.ApplyLogPayload true "Apply log payload"
// @Success 200 {object} map[string]interface{}
// @Router /api/flared/apply-log [post]
func FlaredReportApplyLog(c *gin.Context) {
var payload service.ApplyLogPayload
if !bind.JSON(c, &payload) {
return
}
if authNode, ok := c.Get("flared_node"); ok {
payload.NodeID = authNode.(*model.Node).NodeID
}
log, err := service.ReportApplyLog(payload)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.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) {
authNode, ok := c.Get("flared_node")
if !ok {
response.RespondUnauthorized(c, "无权进行此操作,Tunnel Token 无效")
return
}
node := authNode.(*model.Node)
slog.Debug("flared ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
websocket.Handler(func(conn *websocket.Conn) {
client := service.RegisterFlaredWSClient(node.NodeID)
defer service.UnregisterFlaredWSClient(client)
defer func() {
_ = conn.Close()
slog.Debug("flared ws connection closed", "node_id", node.NodeID)
}()
slog.Debug("flared ws upgrade succeeded", "node_id", node.NodeID, "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(flaredWSReadTimeout()))
if err := websocket.JSON.Receive(conn, &message); err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
slog.Debug("flared ws receive timeout", "node_id", node.NodeID)
return
}
slog.Debug("flared ws receive failed", "node_id", node.NodeID, "error", err)
return
}
slog.Debug("flared ws message received", "node_id", node.NodeID, "type", message.Type)
switch message.Type {
case "ping":
if !service.SendFlaredWSPong(node.NodeID) {
slog.Debug("flared ws pong enqueue failed", "node_id", node.NodeID)
}
case "pong":
slog.Debug("flared ws pong received", "node_id", node.NodeID)
default:
slog.Debug("flared ws unsupported message type", "node_id", node.NodeID, "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", "node_id", client.ID(), "error", err)
return
}
}
}
}
func flaredWSReadTimeout() time.Duration {
timeout := time.Duration(common.AgentHeartbeatInterval) * time.Millisecond * 3
if timeout < 30*time.Second {
return 30 * time.Second
}
return timeout
}
+37
View File
@@ -0,0 +1,37 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
type geoIPLookupRequest struct {
Provider string `json:"provider"`
IP string `json:"ip"`
}
// LookupGeoIP godoc
// @Summary Test GeoIP lookup
// @Tags Options
// @Accept json
// @Produce json
// @Param payload body geoIPLookupRequest true "GeoIP lookup payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/option/geoip/lookup [post]
func LookupGeoIP(c *gin.Context) {
var request geoIPLookupRequest
if !bind.JSON(c, &request) {
return
}
view, err := service.LookupGeoIP(request.Provider, request.IP)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, view)
}
+155
View File
@@ -0,0 +1,155 @@
package controller
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/rain-kl/openflare/openflare-server/common"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/gin-gonic/gin"
)
type GitHubOAuthResponse struct {
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
type GitHubUser struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
}
func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
if code == "" {
return nil, errors.New("无效的参数")
}
values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code}
jsonData, err := json.Marshal(values)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := http.Client{
Timeout: 5 * time.Second,
}
res, err := client.Do(req)
if err != nil {
slog.Error("github oauth access token request failed", "error", err)
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
}
defer res.Body.Close()
var oAuthResponse GitHubOAuthResponse
err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
if err != nil {
return nil, err
}
req, err = http.NewRequest("GET", "https://api.github.com/user", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken))
res2, err := client.Do(req)
if err != nil {
slog.Error("github user info request failed", "error", err)
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
}
defer res2.Body.Close()
var githubUser GitHubUser
err = json.NewDecoder(res2.Body).Decode(&githubUser)
if err != nil {
return nil, err
}
if githubUser.Login == "" {
return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
}
return &githubUser, nil
}
func GitHubOAuth(c *gin.Context) {
if currentUserFromOpenFlareToken(c) != nil {
GitHubBind(c)
return
}
if !common.GitHubOAuthEnabled {
response.RespondFailure(c, "管理员未开启通过 GitHub 登录以及注册")
return
}
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
user := model.User{
GitHubId: githubUser.Login,
}
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
err := user.FillUserByGitHubId()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
} else {
response.RespondFailure(c, "管理员关闭了新用户注册")
return
}
if user.Status != common.UserStatusEnabled {
response.RespondFailure(c, "用户已被封禁")
return
}
setupLogin(&user, c)
}
func GitHubBind(c *gin.Context) {
if !common.GitHubOAuthEnabled {
response.RespondFailure(c, "管理员未开启通过 GitHub 登录以及注册")
return
}
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
user := model.User{
GitHubId: githubUser.Login,
}
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
response.RespondFailure(c, "该 GitHub 账户已被绑定")
return
}
currentUser := currentUserFromOpenFlareToken(c)
if currentUser == nil {
response.RespondFailure(c, "无权进行此操作,未登录或 token 无效")
return
}
user.Id = currentUser.Id
err = user.FillUserById()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
user.GitHubId = githubUser.Login
err = user.Update(false)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "bind")
}
@@ -0,0 +1,117 @@
package controller
import (
"strings"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
// GetManagedDomains godoc
// @Summary List managed domains
// @Tags ManagedDomains
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/managed-domains/ [get]
func GetManagedDomains(c *gin.Context) {
domains, err := service.ListManagedDomains()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, domains)
}
// CreateManagedDomain godoc
// @Summary Create managed domain
// @Tags ManagedDomains
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param payload body service.ManagedDomainInput true "Managed domain payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/managed-domains/ [post]
func CreateManagedDomain(c *gin.Context) {
var input service.ManagedDomainInput
if !bind.JSON(c, &input) {
return
}
domain, err := service.CreateManagedDomain(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, domain)
}
// UpdateManagedDomain godoc
// @Summary Update managed domain
// @Tags ManagedDomains
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Managed domain ID"
// @Param payload body service.ManagedDomainInput true "Managed domain payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/managed-domains/{id}/update [post]
func UpdateManagedDomain(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.ManagedDomainInput
if !bind.JSON(c, &input) {
return
}
domain, err := service.UpdateManagedDomain(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, domain)
}
// DeleteManagedDomain godoc
// @Summary Delete managed domain
// @Tags ManagedDomains
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Managed domain ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/managed-domains/{id}/delete [post]
func DeleteManagedDomain(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
if err := service.DeleteManagedDomain(id); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, nil)
}
// MatchManagedDomainCertificate godoc
// @Summary Match certificate for domain
// @Tags ManagedDomains
// @Produce json
// @Security OpenFlareTokenAuth
// @Param domain query string true "Domain"
// @Success 200 {object} map[string]interface{}
// @Router /api/managed-domains/match [get]
func MatchManagedDomainCertificate(c *gin.Context) {
domain := strings.TrimSpace(c.Query("domain"))
result, err := service.MatchManagedDomainCertificate(domain)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
+147
View File
@@ -0,0 +1,147 @@
package controller
import (
"fmt"
"github.com/rain-kl/openflare/openflare-server/common"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/rain-kl/openflare/openflare-server/utils/mail"
"github.com/rain-kl/openflare/openflare-server/utils/security"
"github.com/rain-kl/openflare/openflare-server/utils/validation"
"github.com/gin-gonic/gin"
)
// GetStatus godoc
// @Summary Get server status
// @Tags Public
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/status [get]
func GetStatus(c *gin.Context) {
authSources, err := service.PublicAuthSources("/api")
if err != nil {
authSources = []service.PublicAuthSource{}
}
response.RespondSuccess(c, gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"system_name": common.SystemName,
"home_page_link": common.HomePageLink,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": common.ServerAddress,
"password_register_enabled": common.PasswordRegisterEnabled,
"auth_sources": authSources,
})
}
func GetNotice(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
response.RespondSuccess(c, common.OptionMap["Notice"])
}
func GetAbout(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
response.RespondSuccess(c, common.OptionMap["About"])
}
func SendEmailVerification(c *gin.Context) {
email := c.Query("email")
if err := validation.Validate.Var(email, "required,email"); err != nil {
response.RespondFailure(c, "无效的参数")
return
}
if model.IsEmailAlreadyTaken(email) {
response.RespondFailure(c, "邮箱地址已被占用")
return
}
code := security.GenerateVerificationCode(6)
security.RegisterVerificationCodeWithKey(email, code, security.EmailVerificationPurpose)
subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s邮箱验证。</p>"+
"<p>您的验证码为: <strong>%s</strong></p>"+
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, security.VerificationValidMinutes)
cfg := mail.SMTPConfig{
Server: common.SMTPServer,
Port: common.SMTPPort,
Account: common.SMTPAccount,
Token: common.SMTPToken,
SystemName: common.SystemName,
}
err := mail.SendEmail(cfg, subject, email, content)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func SendPasswordResetEmail(c *gin.Context) {
email := c.Query("email")
if err := validation.Validate.Var(email, "required,email"); err != nil {
response.RespondFailure(c, "无效的参数")
return
}
if !model.IsEmailAlreadyTaken(email) {
response.RespondFailure(c, "该邮箱地址未注册")
return
}
code := security.GenerateVerificationCode(0)
security.RegisterVerificationCodeWithKey(email, code, security.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", common.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击<a href='%s'>此处</a>进行密码重置。</p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, security.VerificationValidMinutes)
cfg := mail.SMTPConfig{
Server: common.SMTPServer,
Port: common.SMTPPort,
Account: common.SMTPAccount,
Token: common.SMTPToken,
SystemName: common.SystemName,
}
err := mail.SendEmail(cfg, subject, email, content)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
type PasswordResetRequest struct {
Email string `json:"email"`
Token string `json:"token"`
}
func ResetPassword(c *gin.Context) {
var req PasswordResetRequest
if !bind.JSON(c, &req) {
return
}
if req.Email == "" || req.Token == "" {
response.RespondFailure(c, "无效的参数")
return
}
if !security.VerifyCodeWithKey(req.Email, req.Token, security.PasswordResetPurpose) {
response.RespondFailure(c, "重置链接非法或已过期")
return
}
password := security.GenerateVerificationCode(12)
err := model.ResetUserPasswordByEmail(req.Email, password)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
security.DeleteKey(req.Email, security.PasswordResetPurpose)
response.RespondSuccess(c, password)
}
+288
View File
@@ -0,0 +1,288 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
type nodeAgentUpdateRequest struct {
Channel string `json:"channel"`
TagName string `json:"tag_name"`
}
type nodeObservabilityQuery struct {
Hours int `form:"hours"`
Limit int `form:"limit"`
}
// CreateNode godoc
// @Summary Create node
// @Tags Nodes
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param payload body service.NodeInput true "Node payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/ [post]
func CreateNode(c *gin.Context) {
var input service.NodeInput
if !bind.JSON(c, &input) {
return
}
node, err := service.CreateNode(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, node)
}
// GetNodeBootstrapToken godoc
// @Summary Get global discovery token
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/nodes/bootstrap-token [get]
func GetNodeBootstrapToken(c *gin.Context) {
bootstrap, err := service.GetNodeBootstrapView()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, bootstrap)
}
// RotateNodeBootstrapToken godoc
// @Summary Rotate global discovery token
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/nodes/bootstrap-token/rotate [post]
func RotateNodeBootstrapToken(c *gin.Context) {
bootstrap, err := service.RotateGlobalDiscoveryToken()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, bootstrap)
}
// UpdateNode godoc
// @Summary Update node
// @Tags Nodes
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Node ID"
// @Param payload body service.NodeInput true "Node payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/update [post]
func UpdateNode(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.NodeInput
if !bind.JSON(c, &input) {
return
}
node, err := service.UpdateNode(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, node)
}
// DeleteNode godoc
// @Summary Delete node
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Node ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/delete [post]
func DeleteNode(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
if err := service.DeleteNode(id); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
// RequestNodeAgentUpdate godoc
// @Summary Request agent self-update on node
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Node ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/agent-update [post]
func RequestNodeAgentUpdate(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var request nodeAgentUpdateRequest
if c.Request.ContentLength > 0 {
if err := bind.OptionalJSON(c.Request.Body, &request); err != nil {
response.RespondBadRequest(c, "")
return
}
}
node, err := service.RequestNodeAgentUpdate(id, service.NodeAgentUpdateInput{
Channel: request.Channel,
TagName: request.TagName,
})
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, node)
}
// RequestNodeOpenrestyRestart godoc
// @Summary Request openresty restart on node
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Node ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/openresty-restart [post]
func RequestNodeOpenrestyRestart(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
node, err := service.RequestNodeOpenrestyRestart(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, node)
}
// RequestNodeForceSync godoc
// @Summary Request force sync config on node
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Node ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/force-sync [post]
func RequestNodeForceSync(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
node, err := service.RequestNodeForceSync(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, node)
}
// GetNodeAgentRelease godoc
// @Summary Check latest agent release for node
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Node ID"
// @Param channel query string false "stable or preview"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/agent-release [get]
func GetNodeAgentRelease(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
release, err := service.GetNodeAgentRelease(c.Request.Context(), id, c.Query("channel"))
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, release)
}
// GetNodeObservability godoc
// @Summary Get node observability details
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Node ID"
// @Param hours query int false "Lookback window in hours"
// @Param limit query int false "Max records per section"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/observability [get]
func GetNodeObservability(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var query nodeObservabilityQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.RespondBadRequest(c, "")
return
}
view, err := service.GetNodeObservability(id, service.NodeObservabilityQuery{
Hours: query.Hours,
Limit: query.Limit,
})
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, view)
}
// CleanupNodeHealthEvents godoc
// @Summary Cleanup node health events
// @Tags Nodes
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Node ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/observability/cleanup [post]
func CleanupNodeHealthEvents(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
result, err := service.CleanupNodeHealthEvents(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
+417
View File
@@ -0,0 +1,417 @@
package controller
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/rain-kl/openflare/openflare-server/common"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/rain-kl/openflare/openflare-server/utils"
"github.com/rain-kl/openflare/openflare-server/utils/geoip"
"github.com/gin-gonic/gin"
)
var (
openRestySizePattern = regexp.MustCompile(`^\d+[kKmMgG]?$`)
openRestyProxyBuffersPattern = regexp.MustCompile(`^\d+\s+\d+[kKmMgG]?$`)
openRestyCacheLevelsPattern = regexp.MustCompile(`^\d{1,2}(?::\d{1,2}){0,2}$`)
openRestyDurationTokenPattern = regexp.MustCompile(`^\d+[smhdwSMHDW]$`)
)
type optionBatchPayload struct {
Options []model.Option `json:"options"`
}
func validateRateLimitOption(key string, value string) error {
maxDurationSeconds := int(common.RateLimitKeyExpirationDuration.Seconds())
switch key {
case "GlobalApiRateLimitNum", "GlobalWebRateLimitNum", "CriticalRateLimitNum":
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数", key)
}
return nil
case "GlobalApiRateLimitDuration", "GlobalWebRateLimitDuration", "CriticalRateLimitDuration":
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数秒", key)
}
if intValue > maxDurationSeconds {
return fmt.Errorf("%s 不能大于 %d 秒", key, maxDurationSeconds)
}
return nil
default:
return nil
}
}
func validatePositiveIntegerOption(key string, value string) error {
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数", key)
}
return nil
}
func validateBooleanOption(key string, value string) error {
switch value {
case "true", "false":
return nil
default:
return fmt.Errorf("%s 必须为 true 或 false", key)
}
}
func validateGeoIPOption(key string, value string) error {
if key != "GeoIPProvider" {
return nil
}
if !geoip.IsValidProvider(value) {
return fmt.Errorf("%s 仅支持 disabled、mmdb、ip-api、geojs、ipinfo", key)
}
return nil
}
func validateDatabaseCleanupOption(key string, value string) error {
switch key {
case "DatabaseAutoCleanupEnabled":
return validateBooleanOption(key, value)
case "DatabaseAutoCleanupRetentionDays":
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 1 {
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
}
return nil
default:
return nil
}
}
func validateAgentOption(key string, value string) error {
switch key {
case "AgentWebsocketUpgradeEnabled":
return validateBooleanOption(key, strings.TrimSpace(value))
default:
return nil
}
}
func validateUptimeKumaOption(key string, value string, state map[string]string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "UptimeKumaEnabled":
if err := validateBooleanOption(key, trimmed); err != nil {
return err
}
if trimmed == "true" {
url := strings.TrimSpace(state["UptimeKumaUrl"])
username := strings.TrimSpace(state["UptimeKumaUsername"])
password := strings.TrimSpace(state["UptimeKumaPassword"])
if url == "" {
return fmt.Errorf("启用 Uptime Kuma 时地址不能为空")
}
if username == "" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
if password == "" && common.UptimeKumaPassword == "" {
return fmt.Errorf("启用 Uptime Kuma 时密码不能为空")
}
}
case "UptimeKumaUsername":
if trimmed == "" && state["UptimeKumaEnabled"] == "true" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
case "UptimeKumaPassword":
// No specific format checks needed
case "UptimeKumaUrl":
if trimmed != "" {
if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
return fmt.Errorf("Uptime Kuma 地址必须以 http:// 或 https:// 开头")
}
}
case "UptimeKumaMonitorScope":
if trimmed != "all" && trimmed != "selected" {
return fmt.Errorf("监控范围必须为全部站点 (all) 或选择站点 (selected)")
}
case "UptimeKumaSyncInterval", "UptimeKumaInterval", "UptimeKumaRetryInterval", "UptimeKumaTimeout":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
case "UptimeKumaRetry":
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
}
}
return nil
}
func validateOpenRestyOption(key string, value string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "OpenRestyDefaultServerReturnStatus":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
statusCode, _ := strconv.Atoi(trimmed)
if statusCode < 100 || statusCode > 999 {
return fmt.Errorf("%s 必须在 100 到 999 之间", key)
}
return nil
case "OpenRestyWorkerProcesses":
if trimmed == "auto" {
return nil
}
return validatePositiveIntegerOption(key, trimmed)
case "OpenRestyWorkerConnections",
"OpenRestyWorkerRlimitNofile",
"OpenRestyKeepaliveTimeout",
"OpenRestyKeepaliveRequests",
"OpenRestyClientHeaderTimeout",
"OpenRestyClientBodyTimeout",
"OpenRestySendTimeout",
"OpenRestyProxyConnectTimeout",
"OpenRestyProxySendTimeout",
"OpenRestyProxyReadTimeout",
"OpenRestyGzipMinLength":
return validatePositiveIntegerOption(key, trimmed)
case "OpenRestyGzipCompLevel":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
level, _ := strconv.Atoi(trimmed)
if level > 9 {
return fmt.Errorf("%s 不能大于 9", key)
}
return nil
case "OpenRestyEventsUse":
if trimmed == "" {
return nil
}
switch trimmed {
case "epoll", "kqueue", "poll", "select", "rtsig", "/dev/poll", "eventport":
return nil
default:
return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key)
}
case "OpenRestyResolvers":
if trimmed == "" {
return nil
}
if !regexp.MustCompile(`^[a-zA-Z0-9.:\-\s]+$`).MatchString(trimmed) {
return fmt.Errorf("%s 包含非法字符,请填入有效的 IP 地址或域名,以空格分隔", key)
}
return nil
case "OpenRestyEventsMultiAcceptEnabled",
"OpenRestyWebsocketEnabled",
"OpenRestyHTTP3Enabled",
"OpenRestyProxyRequestBufferingEnabled",
"OpenRestyProxyBufferingEnabled",
"OpenRestyGzipEnabled",
"OpenRestyCacheEnabled",
"OpenRestyCacheLockEnabled":
return validateBooleanOption(key, trimmed)
case "OpenRestyProxyBuffers", "OpenRestyLargeClientHeaderBuffers":
if openRestyProxyBuffersPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须类似 \"16 16k\"", key)
case "OpenRestyProxyBufferSize", "OpenRestyProxyBusyBuffersSize", "OpenRestyCacheMaxSize", "OpenRestyClientMaxBodySize":
if openRestySizePattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须为整数或带 k/m/g 单位的大小值", key)
case "OpenRestyCachePath":
if strings.ContainsAny(trimmed, "\r\n\t") {
return fmt.Errorf("%s 不能包含换行或制表符", key)
}
return nil
case "OpenRestyCacheLevels":
if openRestyCacheLevelsPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须类似 \"1:2\" 或 \"1:2:2\"", key)
case "OpenRestyCacheInactive", "OpenRestyCacheLockTimeout":
if openRestyDurationTokenPattern.MatchString(trimmed) {
return nil
}
return fmt.Errorf("%s 格式必须为带单位的时长,例如 30m 或 5s", key)
case "OpenRestyCacheKeyTemplate":
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
}
if strings.ContainsAny(trimmed, "\r\n") {
return fmt.Errorf("%s 不能包含换行", key)
}
return nil
case "OpenRestyCacheUseStale":
if trimmed == "" {
return fmt.Errorf("%s 不能为空", key)
}
allowedTokens := map[string]struct{}{
"error": {}, "timeout": {}, "invalid_header": {}, "updating": {},
"http_500": {}, "http_502": {}, "http_503": {}, "http_504": {},
"http_403": {}, "http_404": {}, "http_429": {}, "off": {},
}
for _, token := range strings.Fields(trimmed) {
if _, ok := allowedTokens[token]; !ok {
return fmt.Errorf("%s 包含不支持的值 %q", key, token)
}
}
return nil
case "OpenRestyMainConfigTemplate":
return service.ValidateOpenRestyMainConfigTemplate(value)
default:
return nil
}
}
func buildOptionValidationState(options []model.Option) map[string]string {
common.OptionMapRWMutex.RLock()
state := make(map[string]string, len(common.OptionMap)+len(options))
for key, value := range common.OptionMap {
state[key] = value
}
common.OptionMapRWMutex.RUnlock()
for _, option := range options {
state[option.Key] = option.Value
}
return state
}
func validateOptionWithState(option model.Option, state map[string]string) error {
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["GitHubClientId"]) == "" {
return fmt.Errorf("无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!")
}
case "WeChatAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["WeChatServerAddress"]) == "" {
return fmt.Errorf("无法启用微信登录,请先填入微信登录相关配置信息!")
}
}
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
return err
}
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
return err
}
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
return err
}
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
return err
}
if err := validateAgentOption(option.Key, option.Value); err != nil {
return err
}
if err := validateUptimeKumaOption(option.Key, option.Value, state); err != nil {
return err
}
return nil
}
func updateOptions(options []model.Option) error {
if len(options) == 0 {
return fmt.Errorf("无效的参数")
}
state := buildOptionValidationState(options)
for _, option := range options {
if strings.TrimSpace(option.Key) == "" {
return fmt.Errorf("无效的参数")
}
if err := validateOptionWithState(option, state); err != nil {
return err
}
}
return model.UpdateOptions(options)
}
// GetOptions godoc
// @Summary List editable options
// @Tags Options
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/option/ [get]
func GetOptions(c *gin.Context) {
var options []*model.Option
common.OptionMapRWMutex.RLock()
for k, v := range common.OptionMap {
if strings.Contains(k, "Token") || strings.Contains(k, "Secret") || strings.Contains(k, "Password") {
continue
}
options = append(options, &model.Option{
Key: k,
Value: utils.Interface2String(v),
})
}
common.OptionMapRWMutex.RUnlock()
response.RespondSuccess(c, options)
}
// UpdateOption godoc
// @Summary Update option
// @Tags Options
// @Accept json
// @Produce json
// @Param payload body model.Option true "Option payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/option/update [post]
func UpdateOption(c *gin.Context) {
var option model.Option
if !bind.JSON(c, &option) {
return
}
state := buildOptionValidationState([]model.Option{option})
if err := validateOptionWithState(option, state); err != nil {
response.RespondFailure(c, err.Error())
return
}
err := model.UpdateOption(option.Key, option.Value)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
// UpdateOptionsBatch godoc
// @Summary Batch update options
// @Tags Options
// @Accept json
// @Produce json
// @Param payload body optionBatchPayload true "Batch option payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/option/update-batch [post]
func UpdateOptionsBatch(c *gin.Context) {
var payload optionBatchPayload
if !bind.JSON(c, &payload) {
return
}
if len(payload.Options) == 0 {
response.RespondBadRequest(c, "无效的参数")
return
}
if err := updateOptions(payload.Options); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
+119
View File
@@ -0,0 +1,119 @@
package controller
import (
"testing"
)
func TestValidateOpenRestyOption(t *testing.T) {
testCases := []struct {
name string
key string
value string
wantErr bool
}{
{name: "default server status valid 421", key: "OpenRestyDefaultServerReturnStatus", value: "421"},
{name: "default server status valid 200", key: "OpenRestyDefaultServerReturnStatus", value: "200"},
{name: "default server status invalid 99", key: "OpenRestyDefaultServerReturnStatus", value: "99", wantErr: true},
{name: "default server status invalid 1000", key: "OpenRestyDefaultServerReturnStatus", value: "1000", wantErr: true},
{name: "default server status invalid abc", key: "OpenRestyDefaultServerReturnStatus", value: "abc", wantErr: true},
{name: "worker processes auto", key: "OpenRestyWorkerProcesses", value: "auto"},
{name: "worker processes number", key: "OpenRestyWorkerProcesses", value: "8"},
{name: "worker processes invalid", key: "OpenRestyWorkerProcesses", value: "0", wantErr: true},
{name: "events use empty", key: "OpenRestyEventsUse", value: ""},
{name: "events use invalid", key: "OpenRestyEventsUse", value: "io_uring", wantErr: true},
{name: "resolvers valid", key: "OpenRestyResolvers", value: "1.1.1.1 8.8.8.8"},
{name: "resolvers invalid", key: "OpenRestyResolvers", value: "1.1.1.1; 8.8.8.8", wantErr: true},
{name: "proxy buffers valid", key: "OpenRestyProxyBuffers", value: "16 16k"},
{name: "proxy buffers invalid", key: "OpenRestyProxyBuffers", value: "16x16k", wantErr: true},
{name: "cache max size valid", key: "OpenRestyCacheMaxSize", value: "2g"},
{name: "cache max size invalid", key: "OpenRestyCacheMaxSize", value: "2gb", wantErr: true},
{name: "client max body size valid", key: "OpenRestyClientMaxBodySize", value: "64m"},
{name: "client max body size invalid", key: "OpenRestyClientMaxBodySize", value: "64mb", wantErr: true},
{name: "large client header buffers valid", key: "OpenRestyLargeClientHeaderBuffers", value: "4 16k"},
{name: "large client header buffers invalid", key: "OpenRestyLargeClientHeaderBuffers", value: "4x16k", wantErr: true},
{name: "proxy request buffering valid", key: "OpenRestyProxyRequestBufferingEnabled", value: "true"},
{name: "proxy request buffering invalid", key: "OpenRestyProxyRequestBufferingEnabled", value: "on", wantErr: true},
{name: "websocket valid", key: "OpenRestyWebsocketEnabled", value: "false"},
{name: "websocket invalid", key: "OpenRestyWebsocketEnabled", value: "off", wantErr: true},
{name: "cache inactive valid", key: "OpenRestyCacheInactive", value: "30m"},
{name: "cache inactive invalid", key: "OpenRestyCacheInactive", value: "30", wantErr: true},
{name: "cache use stale valid", key: "OpenRestyCacheUseStale", value: "error timeout http_500"},
{name: "cache use stale invalid", key: "OpenRestyCacheUseStale", value: "error whatever", wantErr: true},
{name: "gzip level valid", key: "OpenRestyGzipCompLevel", value: "9"},
{name: "gzip level invalid", key: "OpenRestyGzipCompLevel", value: "10", wantErr: true},
}
for _, testCase := range testCases {
err := validateOpenRestyOption(testCase.key, testCase.value)
if testCase.wantErr && err == nil {
t.Fatalf("%s: expected error", testCase.name)
}
if !testCase.wantErr && err != nil {
t.Fatalf("%s: unexpected error: %v", testCase.name, err)
}
}
}
func TestValidateAgentOption(t *testing.T) {
if err := validateAgentOption("AgentWebsocketUpgradeEnabled", "true"); err != nil {
t.Fatalf("expected websocket upgrade option to accept true: %v", err)
}
if err := validateAgentOption("AgentWebsocketUpgradeEnabled", "false"); err != nil {
t.Fatalf("expected websocket upgrade option to accept false: %v", err)
}
if err := validateAgentOption("AgentWebsocketUpgradeEnabled", "on"); err == nil {
t.Fatal("expected websocket upgrade option to reject non-boolean value")
}
}
func TestValidateUptimeKumaOption(t *testing.T) {
state := map[string]string{
"UptimeKumaUrl": "http://localhost:3001",
"UptimeKumaUsername": "admin",
"UptimeKumaPassword": "password",
}
testCases := []struct {
name string
key string
value string
wantErr bool
}{
{name: "enabled true", key: "UptimeKumaEnabled", value: "true"},
{name: "enabled false", key: "UptimeKumaEnabled", value: "false"},
{name: "enabled invalid", key: "UptimeKumaEnabled", value: "on", wantErr: true},
{name: "url http valid", key: "UptimeKumaUrl", value: "http://192.168.1.100:3001"},
{name: "url https valid", key: "UptimeKumaUrl", value: "https://kuma.example.com"},
{name: "url invalid", key: "UptimeKumaUrl", value: "kuma.example.com", wantErr: true},
{name: "scope all", key: "UptimeKumaMonitorScope", value: "all"},
{name: "scope selected", key: "UptimeKumaMonitorScope", value: "selected"},
{name: "scope invalid", key: "UptimeKumaMonitorScope", value: "none", wantErr: true},
{name: "sync interval valid", key: "UptimeKumaSyncInterval", value: "5"},
{name: "sync interval invalid", key: "UptimeKumaSyncInterval", value: "0", wantErr: true},
{name: "interval valid", key: "UptimeKumaInterval", value: "60"},
{name: "interval invalid", key: "UptimeKumaInterval", value: "-60", wantErr: true},
{name: "retry valid", key: "UptimeKumaRetry", value: "0"},
{name: "retry positive valid", key: "UptimeKumaRetry", value: "3"},
{name: "retry invalid", key: "UptimeKumaRetry", value: "-1", wantErr: true},
}
for _, tc := range testCases {
err := validateUptimeKumaOption(tc.key, tc.value, state)
if tc.wantErr && err == nil {
t.Fatalf("%s: expected error", tc.name)
}
if !tc.wantErr && err != nil {
t.Fatalf("%s: unexpected error: %v", tc.name, err)
}
}
// Test enabling Uptime Kuma when URL or credentials are empty in state
stateEmpty := map[string]string{
"UptimeKumaUrl": "",
"UptimeKumaUsername": "",
"UptimeKumaPassword": "",
}
if err := validateUptimeKumaOption("UptimeKumaEnabled", "true", stateEmpty); err == nil {
t.Fatal("expected error when enabling Uptime Kuma with empty URL/credentials in state")
}
}
+73
View File
@@ -0,0 +1,73 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
func GetOrigins(c *gin.Context) {
origins, err := service.ListOrigins()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, origins)
}
func GetOrigin(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
origin, err := service.GetOriginDetail(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, origin)
}
func CreateOrigin(c *gin.Context) {
var input service.OriginInput
if !bind.JSON(c, &input) {
return
}
origin, err := service.CreateOrigin(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, origin)
}
func UpdateOrigin(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.OriginInput
if !bind.JSON(c, &input) {
return
}
origin, err := service.UpdateOrigin(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, origin)
}
func DeleteOrigin(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
if err := service.DeleteOrigin(id); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, nil)
}
+170
View File
@@ -0,0 +1,170 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
func ListPagesProjects(c *gin.Context) {
projects, err := service.ListPagesProjects()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, projects)
}
func GetPagesProject(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
project, err := service.GetPagesProject(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, project)
}
func CreatePagesProject(c *gin.Context) {
var input service.PagesProjectInput
if !bind.JSON(c, &input) {
return
}
project, err := service.CreatePagesProject(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, project)
}
func UpdatePagesProject(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.PagesProjectInput
if !bind.JSON(c, &input) {
return
}
project, err := service.UpdatePagesProject(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, project)
}
func DeletePagesProject(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
if err := service.DeletePagesProject(id); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, nil)
}
func ListPagesDeployments(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
deployments, err := service.ListPagesProjectDeployments(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, deployments)
}
func UploadPagesDeployment(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
file, err := c.FormFile("package")
if err != nil {
response.RespondBadRequest(c, "缺少 Pages 部署包")
return
}
deployment, err := service.UploadPagesDeployment(
id,
file,
c.PostForm("root_dir"),
c.PostForm("entry_file"),
c.GetString("username"),
)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, deployment)
}
func ActivatePagesDeployment(c *gin.Context) {
projectID, ok := bind.IDParam(c)
if !ok {
return
}
deploymentID, ok := bind.IDParamByName(c, "deployment_id")
if !ok {
return
}
project, err := service.ActivatePagesDeployment(projectID, deploymentID)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, project)
}
func DeletePagesDeployment(c *gin.Context) {
projectID, ok := bind.IDParam(c)
if !ok {
return
}
deploymentID, ok := bind.IDParamByName(c, "deployment_id")
if !ok {
return
}
if err := service.DeletePagesDeployment(projectID, deploymentID); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, nil)
}
func ListPagesDeploymentFiles(c *gin.Context) {
deploymentID, ok := bind.IDParamByName(c, "deployment_id")
if !ok {
return
}
files, err := service.ListPagesDeploymentFiles(deploymentID)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, files)
}
func AgentDownloadPagesDeploymentPackage(c *gin.Context) {
deploymentID, ok := bind.IDParamByName(c, "deployment_id")
if !ok {
return
}
filePath, fileName, err := service.GetPagesDeploymentPackagePath(deploymentID)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
c.Header("Content-Disposition", "attachment; filename="+fileName)
c.File(filePath)
}
+119
View File
@@ -0,0 +1,119 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
// GetProxyRoutes godoc
// @Summary List proxy routes
// @Tags ProxyRoutes
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/proxy-routes/ [get]
func GetProxyRoutes(c *gin.Context) {
routes, err := service.ListProxyRoutes()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, routes)
}
// GetProxyRoute godoc
// @Summary Get proxy route detail
// @Tags ProxyRoutes
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Route ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/proxy-routes/{id} [get]
func GetProxyRoute(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
route, err := service.GetProxyRoute(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, route)
}
// CreateProxyRoute godoc
// @Summary Create proxy route
// @Tags ProxyRoutes
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param payload body service.ProxyRouteInput true "Proxy route payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/proxy-routes/ [post]
func CreateProxyRoute(c *gin.Context) {
var input service.ProxyRouteInput
if !bind.JSON(c, &input) {
return
}
route, err := service.CreateProxyRoute(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, route)
}
// UpdateProxyRoute godoc
// @Summary Update proxy route
// @Tags ProxyRoutes
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Route ID"
// @Param payload body service.ProxyRouteInput true "Proxy route payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/proxy-routes/{id}/update [post]
func UpdateProxyRoute(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.ProxyRouteInput
if !bind.JSON(c, &input) {
return
}
route, err := service.UpdateProxyRoute(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, route)
}
// DeleteProxyRoute godoc
// @Summary Delete proxy route
// @Tags ProxyRoutes
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Route ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/proxy-routes/{id}/delete [post]
func DeleteProxyRoute(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
if err := service.DeleteProxyRoute(id); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, nil)
}
+121
View File
@@ -0,0 +1,121 @@
package controller
import (
"log/slog"
"net"
"time"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
"golang.org/x/net/websocket"
)
// RelayHeartbeat godoc
// @Summary Report relay heartbeat
// @Tags Relay
// @Accept json
// @Produce json
// @Security AccessTokenAuth
// @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 !bind.JSON(c, &payload) {
return
}
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
authNode, ok := c.Get("relay_node")
if !ok {
response.RespondUnauthorized(c, "无权进行此操作")
return
}
node := authNode.(*model.Node)
result, err := service.HeartbeatRelay(node, payload)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
// RelayWebSocket godoc
// @Summary Upgrade relay connection to websocket
// @Tags Relay
// @Security AccessTokenAuth
// @Router /api/relay/ws [get]
func RelayWebSocket(c *gin.Context) {
authNode, ok := c.Get("relay_node")
if !ok {
response.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,282 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
// GetTLSCertificates godoc
// @Summary List TLS certificates
// @Tags TLSCertificates
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/tls-certificates/ [get]
func GetTLSCertificates(c *gin.Context) {
certificates, err := service.ListTLSCertificates()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, certificates)
}
// GetTLSCertificate godoc
// @Summary Get TLS certificate detail
// @Tags TLSCertificates
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Certificate ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id} [get]
func GetTLSCertificate(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
certificate, err := service.GetTLSCertificate(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, certificate)
}
// GetTLSCertificateContent godoc
// @Summary Get TLS certificate PEM content
// @Tags TLSCertificates
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Certificate ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id}/content [get]
func GetTLSCertificateContent(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
content, err := service.GetTLSCertificateContent(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, content)
}
// CreateTLSCertificate godoc
// @Summary Create TLS certificate from PEM
// @Tags TLSCertificates
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param payload body service.TLSCertificateInput true "TLS certificate payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/ [post]
func CreateTLSCertificate(c *gin.Context) {
var input service.TLSCertificateInput
if !bind.JSON(c, &input) {
return
}
certificate, err := service.CreateTLSCertificate(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, certificate)
}
// UpdateTLSCertificate godoc
// @Summary Update TLS certificate from PEM
// @Tags TLSCertificates
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Certificate ID"
// @Param payload body service.TLSCertificateInput true "TLS certificate payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id}/update [post]
func UpdateTLSCertificate(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.TLSCertificateInput
if !bind.JSON(c, &input) {
return
}
certificate, err := service.UpdateTLSCertificate(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, certificate)
}
// ImportTLSCertificateFile godoc
// @Summary Import TLS certificate from files
// @Tags TLSCertificates
// @Accept multipart/form-data
// @Produce json
// @Security OpenFlareTokenAuth
// @Param name formData string true "Certificate name"
// @Param remark formData string false "Remark"
// @Param cert_file formData file true "Certificate file"
// @Param key_file formData file true "Private key file"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/import-file [post]
func ImportTLSCertificateFile(c *gin.Context) {
name := c.PostForm("name")
remark := c.PostForm("remark")
certFile, err := c.FormFile("cert_file")
if err != nil {
response.RespondBadRequest(c, "缺少证书文件")
return
}
keyFile, err := c.FormFile("key_file")
if err != nil {
response.RespondBadRequest(c, "缺少私钥文件")
return
}
certificate, err := service.CreateTLSCertificateFromFiles(name, certFile, keyFile, remark)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, certificate)
}
// DeleteTLSCertificate godoc
// @Summary Delete TLS certificate
// @Tags TLSCertificates
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Certificate ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id}/delete [post]
func DeleteTLSCertificate(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
if err := service.DeleteTLSCertificate(id); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, nil)
}
// ApplyTLSCertificate godoc
// @Summary Apply TLS certificate via ACME
// @Tags TLSCertificates
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param payload body service.TLSApplyInput true "TLS apply payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/apply [post]
func ApplyTLSCertificate(c *gin.Context) {
var input service.TLSApplyInput
if !bind.JSON(c, &input) {
return
}
certificate, err := service.ApplyTLSCertificate(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, certificate)
}
// UpdateAcmeCertificate godoc
// @Summary Update ACME TLS certificate
// @Tags TLSCertificates
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Certificate ID"
// @Param payload body service.TLSApplyInput true "TLS apply payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id}/update-acme [post]
func UpdateAcmeCertificate(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.TLSApplyInput
if !bind.JSON(c, &input) {
return
}
certificate, err := service.UpdateAcmeCertificate(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, certificate)
}
// ConvertTLSCertificateToAcme godoc
// @Summary Convert uploaded TLS certificate to ACME managed certificate
// @Tags TLSCertificates
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Certificate ID"
// @Param payload body service.TLSApplyInput true "TLS apply payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id}/convert-acme [post]
func ConvertTLSCertificateToAcme(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.TLSApplyInput
if !bind.JSON(c, &input) {
return
}
certificate, err := service.ConvertTLSCertificateToAcme(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, certificate)
}
// RenewTLSCertificate godoc
// @Summary Renew TLS certificate
// @Tags TLSCertificates
// @Produce json
// @Security OpenFlareTokenAuth
// @Param id path int true "Certificate ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id}/renew [post]
func RenewTLSCertificate(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
certificate, err := service.RenewTLSCertificate(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, certificate)
}
+166
View File
@@ -0,0 +1,166 @@
package controller
import (
"time"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
"golang.org/x/net/websocket"
)
type confirmManualUpgradeRequest struct {
UploadToken string `json:"upload_token"`
}
type serverUpgradeRequest struct {
Channel string `json:"channel"`
}
// GetLatestRelease godoc
// @Summary Get latest GitHub release
// @Tags Update
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/update/latest-release [get]
func GetLatestRelease(c *gin.Context) {
release, err := service.GetLatestServerRelease(c.Request.Context(), c.Query("channel"))
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, release)
}
// UpgradeServer godoc
// @Summary Upgrade server binary from latest GitHub release
// @Tags Update
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/update/upgrade [post]
func UpgradeServer(c *gin.Context) {
var request serverUpgradeRequest
if c.Request.ContentLength > 0 {
if err := bind.OptionalJSON(c.Request.Body, &request); err != nil {
response.RespondBadRequest(c, "无效的参数")
return
}
}
release, err := service.ScheduleServerUpgrade(request.Channel)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessWithExtras(c, release, gin.H{
"message": "服务升级任务已启动,下载完成后将自动重启。",
})
}
// StreamServerUpgradeLogs godoc
// @Summary Stream server upgrade logs over websocket
// @Tags Update
// @Router /api/update/logs/ws [get]
func StreamServerUpgradeLogs(c *gin.Context) {
websocket.Handler(func(conn *websocket.Conn) {
defer func() {
_ = conn.Close()
}()
updates, unsubscribe := service.SubscribeServerUpgradeStream()
defer unsubscribe()
heartbeatTicker := time.NewTicker(15 * time.Second)
defer heartbeatTicker.Stop()
for {
select {
case snapshot, ok := <-updates:
if !ok {
return
}
if err := websocket.JSON.Send(conn, snapshot); err != nil {
return
}
case <-heartbeatTicker.C:
if err := websocket.JSON.Send(conn, service.ServerUpgradeStreamSnapshot{}); err != nil {
return
}
case <-c.Request.Context().Done():
return
}
}
}).ServeHTTP(c.Writer, c.Request)
}
// UploadManualServerBinary godoc
// @Summary Upload server binary and inspect version before upgrade
// @Tags Update
// @Accept mpfd
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/update/manual-upload [post]
func UploadManualServerBinary(c *gin.Context) {
response.RespondFailure(c, "手动升级功能已禁用")
return
//
//fileHeader, err := c.FormFile("binary")
//if err != nil {
// response.RespondFailure(c, "请先选择要上传的服务端二进制文件。")
// return
//}
//
//file, err := fileHeader.Open()
//if err != nil {
// response.RespondFailure(c, "读取上传文件失败。")
// return
//}
//defer func() {
// _ = file.Close()
//}()
//
//info, err := service.UploadManualServerBinary(c.Request.Context(), fileHeader.Filename, file)
//if err != nil {
// response.RespondFailure(c, err.Error())
// return
//}
//
//message := strings.TrimSpace(info.ComparisonMessage)
//if message == "" {
// message = "已完成上传并检查升级包版本。"
//}
//
//response.RespondSuccessWithExtras(c, info, gin.H{
// "message": message,
//})
}
// ConfirmManualServerUpgrade godoc
// @Summary Confirm upgrade with previously uploaded server binary
// @Tags Update
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/update/manual-upgrade [post]
func ConfirmManualServerUpgrade(c *gin.Context) {
response.RespondFailure(c, "手动升级功能已禁用")
return
//
//var request confirmManualUpgradeRequest
//if !bind.JSON(c, &request) {
// return
//}
//
//info, err := service.ConfirmManualServerUpgrade(request.UploadToken)
//if err != nil {
// response.RespondFailure(c, err.Error())
// return
//}
//
//response.RespondSuccessWithExtras(c, info, gin.H{
// "message": "服务升级任务已启动,确认无误后将自动重启。",
//})
}
+25
View File
@@ -0,0 +1,25 @@
package controller
import (
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
// SyncUptimeKuma godoc
// @Summary Manually trigger Uptime Kuma sync
// @Tags UptimeKuma
// @Accept json
// @Produce json
// @Security OpenFlareTokenAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/uptimekuma/sync [post]
func SyncUptimeKuma(c *gin.Context) {
err := service.SyncToUptimeKuma()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "同步成功")
}
+415
View File
@@ -0,0 +1,415 @@
package controller
import (
"strconv"
"github.com/rain-kl/openflare/openflare-server/common"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/middleware"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/rain-kl/openflare/openflare-server/utils/security"
"github.com/rain-kl/openflare/openflare-server/utils/validation"
"github.com/gin-gonic/gin"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func Login(c *gin.Context) {
if !common.PasswordLoginEnabled {
response.RespondFailure(c, "管理员关闭了密码登录")
return
}
var loginRequest LoginRequest
if !bind.JSON(c, &loginRequest) {
return
}
username := loginRequest.Username
password := loginRequest.Password
if username == "" || password == "" {
response.RespondFailure(c, "无效的参数")
return
}
user := model.User{
Username: username,
Password: password,
}
err := user.ValidateAndFill()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
setupLogin(&user, c)
}
// setup token and then return user info
func setLoginToken(user *model.User) (*model.User, error) {
// Generate a signed JWT using gin-jwt middleware
tokenString, _, err := middleware.JWTMiddleware.TokenGenerator(user)
if err != nil {
return nil, err
}
// Persist JWT in DB so we can invalidate it on logout
if err := model.DB.Model(user).Update("token", tokenString).Error; err != nil {
return nil, err
}
cleanUser := &model.User{
Id: user.Id,
Username: user.Username,
DisplayName: user.DisplayName,
Role: user.Role,
Status: user.Status,
Token: tokenString,
}
return cleanUser, nil
}
func setupLogin(user *model.User, c *gin.Context) {
cleanUser, err := setLoginToken(user)
if err != nil {
response.RespondFailure(c, "无法保存会话信息,请重试")
return
}
response.RespondSuccess(c, *cleanUser)
}
func Logout(c *gin.Context) {
token := c.GetHeader("OpenFlare-Token")
if token != "" {
user := model.ValidateUserToken(token)
if user != nil && user.Id != 0 {
if err := model.DB.Model(user).Update("token", "").Error; err != nil {
response.RespondFailure(c, err.Error())
return
}
}
}
response.RespondSuccessMessage(c, "")
}
func currentUserFromOpenFlareToken(c *gin.Context) *model.User {
token := c.GetHeader("OpenFlare-Token")
if token == "" {
return nil
}
return model.ValidateUserToken(token)
}
func Register(c *gin.Context) {
response.RespondFailure(c, "非法请求")
}
func GetAllUsers(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
}
users, err := model.GetAllUsers(p*common.ItemsPerPage, common.ItemsPerPage)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, users)
}
func SearchUsers(c *gin.Context) {
keyword := c.Query("keyword")
users, err := model.SearchUsers(keyword)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, users)
}
func GetUser(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
user, err := model.GetUserById(int(id), false)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
myRole := c.GetInt("role")
if myRole <= user.Role {
response.RespondFailure(c, "无权获取同级或更高等级用户的信息")
return
}
response.RespondSuccess(c, user)
}
func GenerateToken(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
// Generate a fresh JWT for the user
tokenString, _, err := middleware.JWTMiddleware.TokenGenerator(user)
if err != nil {
response.RespondFailure(c, "生成 Token 失败: "+err.Error())
return
}
user.Token = tokenString
if err := user.Update(false); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, user.Token)
}
func GetSelf(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, user)
}
func UpdateUser(c *gin.Context) {
var updatedUser model.User
if !bind.JSON(c, &updatedUser) {
return
}
if updatedUser.Id == 0 {
response.RespondFailure(c, "无效的参数")
return
}
if updatedUser.Password == "" {
updatedUser.Password = "$I_LOVE_U" // make Validator happy :)
}
if err := validation.Validate.Struct(&updatedUser); err != nil {
response.RespondFailure(c, "输入不合法 "+err.Error())
return
}
originUser, err := model.GetUserById(updatedUser.Id, false)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
myRole := c.GetInt("role")
if myRole <= originUser.Role {
response.RespondFailure(c, "无权更新同权限等级或更高权限等级的用户信息")
return
}
if myRole <= updatedUser.Role {
response.RespondFailure(c, "无权将其他用户权限等级提升到大于等于自己的权限等级")
return
}
if updatedUser.Password == "$I_LOVE_U" {
updatedUser.Password = "" // rollback to what it should be
}
updatePassword := updatedUser.Password != ""
if err := updatedUser.Update(updatePassword); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func UpdateSelf(c *gin.Context) {
var user model.User
if !bind.JSON(c, &user) {
return
}
if user.Password == "" {
user.Password = "$I_LOVE_U" // make Validator happy :)
}
if err := validation.Validate.Struct(&user); err != nil {
response.RespondFailure(c, "输入不合法 "+err.Error())
return
}
cleanUser := model.User{
Id: c.GetInt("id"),
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
}
if user.Password == "$I_LOVE_U" {
user.Password = "" // rollback to what it should be
cleanUser.Password = ""
}
updatePassword := user.Password != ""
if err := cleanUser.Update(updatePassword); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func DeleteUser(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
originUser, err := model.GetUserById(int(id), false)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
myRole := c.GetInt("role")
if myRole <= originUser.Role {
response.RespondFailure(c, "无权删除同权限等级或更高权限等级的用户")
return
}
err = model.DeleteUserById(int(id))
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func DeleteSelf(c *gin.Context) {
id := c.GetInt("id")
err := model.DeleteUserById(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func CreateUser(c *gin.Context) {
var user model.User
if !bind.JSON(c, &user) {
return
}
if user.Username == "" || user.Password == "" {
response.RespondFailure(c, "无效的参数")
return
}
if user.DisplayName == "" {
user.DisplayName = user.Username
}
myRole := c.GetInt("role")
if user.Role >= myRole {
response.RespondFailure(c, "无法创建权限大于等于自己的用户")
return
}
// Even for admin users, we cannot fully trust them!
cleanUser := model.User{
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
}
if err := cleanUser.Insert(); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
type ManageRequest struct {
Username string `json:"username"`
Action string `json:"action"`
}
// ManageUser Only admin user can do this
func ManageUser(c *gin.Context) {
var req ManageRequest
if !bind.JSON(c, &req) {
return
}
user := model.User{
Username: req.Username,
}
// Fill attributes
model.DB.Where(&user).First(&user)
if user.Id == 0 {
response.RespondFailure(c, "用户不存在")
return
}
myRole := c.GetInt("role")
if myRole <= user.Role && myRole != common.RoleRootUser {
response.RespondFailure(c, "无权更新同权限等级或更高权限等级的用户信息")
return
}
switch req.Action {
case "disable":
user.Status = common.UserStatusDisabled
if user.Role == common.RoleRootUser {
response.RespondFailure(c, "无法禁用超级管理员用户")
return
}
case "enable":
user.Status = common.UserStatusEnabled
case "delete":
if user.Role == common.RoleRootUser {
response.RespondFailure(c, "无法删除超级管理员用户")
return
}
if err := user.Delete(); err != nil {
response.RespondFailure(c, err.Error())
return
}
case "promote":
if myRole != common.RoleRootUser {
response.RespondFailure(c, "普通管理员用户无法提升其他用户为管理员")
return
}
if user.Role >= common.RoleAdminUser {
response.RespondFailure(c, "该用户已经是管理员")
return
}
user.Role = common.RoleAdminUser
case "demote":
if user.Role == common.RoleRootUser {
response.RespondFailure(c, "无法降级超级管理员用户")
return
}
if user.Role == common.RoleCommonUser {
response.RespondFailure(c, "该用户已经是普通用户")
return
}
user.Role = common.RoleCommonUser
}
if err := user.Update(false); err != nil {
response.RespondFailure(c, err.Error())
return
}
clearUser := model.User{
Role: user.Role,
Status: user.Status,
}
response.RespondSuccess(c, clearUser)
}
func EmailBind(c *gin.Context) {
email := c.Query("email")
code := c.Query("code")
if !security.VerifyCodeWithKey(email, code, security.EmailVerificationPurpose) {
response.RespondFailure(c, "验证码错误或已过期")
return
}
id := c.GetInt("id")
user := model.User{
Id: id,
}
err := user.FillUserById()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
user.Email = email
// no need to check if this email already taken, because we have used verification code to check it
err = user.Update(false)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
+225
View File
@@ -0,0 +1,225 @@
package controller
import (
"strconv"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/controller/bind"
"github.com/rain-kl/openflare/openflare-server/service"
"github.com/gin-gonic/gin"
)
type wafIDsRequest struct {
IDs []uint `json:"ids"`
}
func ListWAFRuleGroups(c *gin.Context) {
groups, err := service.ListWAFRuleGroups()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, groups)
}
func GetWAFRuleGroup(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
group, err := service.GetWAFRuleGroup(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, group)
}
func CreateWAFRuleGroup(c *gin.Context) {
var input service.WAFRuleGroupInput
if !bind.JSON(c, &input) {
return
}
group, err := service.CreateWAFRuleGroup(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, group)
}
func UpdateWAFRuleGroup(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.WAFRuleGroupInput
if !bind.JSON(c, &input) {
return
}
group, err := service.UpdateWAFRuleGroup(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, group)
}
func DeleteWAFRuleGroup(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
if err := service.DeleteWAFRuleGroup(id); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func ReplaceWAFRuleGroupSites(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var request wafIDsRequest
if !bind.JSON(c, &request) {
return
}
group, err := service.ReplaceWAFRuleGroupSites(id, request.IDs)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, group)
}
func GetWAFSiteRuleGroups(c *gin.Context) {
routeID, ok := parseUintPathParam(c, "route_id")
if !ok {
return
}
view, err := service.GetWAFSiteRuleGroups(routeID)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, view)
}
func ReplaceWAFSiteRuleGroups(c *gin.Context) {
routeID, ok := parseUintPathParam(c, "route_id")
if !ok {
return
}
var request wafIDsRequest
if !bind.JSON(c, &request) {
return
}
view, err := service.ReplaceWAFSiteRuleGroups(routeID, request.IDs)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, view)
}
func ListWAFIPGroups(c *gin.Context) {
groups, err := service.ListWAFIPGroups()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, groups)
}
func GetWAFIPGroup(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
group, err := service.GetWAFIPGroup(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, group)
}
func CreateWAFIPGroup(c *gin.Context) {
var input service.WAFIPGroupInput
if !bind.JSON(c, &input) {
return
}
group, err := service.CreateWAFIPGroup(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, group)
}
func UpdateWAFIPGroup(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
var input service.WAFIPGroupInput
if !bind.JSON(c, &input) {
return
}
group, err := service.UpdateWAFIPGroup(id, input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, group)
}
func DeleteWAFIPGroup(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
if err := service.DeleteWAFIPGroup(id); err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
}
func SyncWAFIPGroup(c *gin.Context) {
id, ok := bind.IDParam(c)
if !ok {
return
}
result, err := service.SyncWAFIPGroup(id)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
func TestWAFIPGroupAutoConfig(c *gin.Context) {
var input service.WAFIPGroupAutoTestInput
if !bind.JSON(c, &input) {
return
}
result, err := service.TestWAFIPGroupAutoConfig(input)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccess(c, result)
}
func parseUintPathParam(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
response.RespondBadRequest(c, "invalid id")
return 0, false
}
return uint(id), true
}
+125
View File
@@ -0,0 +1,125 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/rain-kl/openflare/openflare-server/common"
"github.com/rain-kl/openflare/openflare-server/common/response"
"github.com/rain-kl/openflare/openflare-server/model"
"github.com/gin-gonic/gin"
)
type wechatLoginResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data string `json:"data"`
}
func getWeChatIdByCode(code string) (string, error) {
if code == "" {
return "", errors.New("无效的参数")
}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", common.WeChatServerToken)
client := http.Client{
Timeout: 5 * time.Second,
}
httpResponse, err := client.Do(req)
if err != nil {
return "", err
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
slog.Error("Failed to close response body", "error", err)
}
}(httpResponse.Body)
var res wechatLoginResponse
err = json.NewDecoder(httpResponse.Body).Decode(&res)
if err != nil {
return "", err
}
if !res.Success {
return "", errors.New(res.Message)
}
if res.Data == "" {
return "", errors.New("验证码错误或已过期")
}
return res.Data, nil
}
func WeChatAuth(c *gin.Context) {
if !common.WeChatAuthEnabled {
response.RespondFailure(c, "管理员未开启通过微信登录以及注册")
return
}
code := c.Query("code")
wechatId, err := getWeChatIdByCode(code)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
user := model.User{
WeChatId: wechatId,
}
if model.IsWeChatIdAlreadyTaken(wechatId) {
err := user.FillUserByWeChatId()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
} else {
response.RespondFailure(c, "管理员关闭了新用户注册")
return
}
if user.Status != common.UserStatusEnabled {
response.RespondFailure(c, "用户已被封禁")
return
}
setupLogin(&user, c)
}
func WeChatBind(c *gin.Context) {
if !common.WeChatAuthEnabled {
response.RespondFailure(c, "管理员未开启通过微信登录以及注册")
return
}
code := c.Query("code")
wechatId, err := getWeChatIdByCode(code)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
if model.IsWeChatIdAlreadyTaken(wechatId) {
response.RespondFailure(c, "该微信账号已被绑定")
return
}
id := c.GetInt("id")
user := model.User{
Id: id,
}
err = user.FillUserById()
if err != nil {
response.RespondFailure(c, err.Error())
return
}
user.WeChatId = wechatId
err = user.Update(false)
if err != nil {
response.RespondFailure(c, err.Error())
return
}
response.RespondSuccessMessage(c, "")
return
}