diff --git a/EdgeAPI/internal/accesslogs/storage_manager.go b/EdgeAPI/internal/accesslogs/storage_manager.go
index 53b1078..7639c47 100644
--- a/EdgeAPI/internal/accesslogs/storage_manager.go
+++ b/EdgeAPI/internal/accesslogs/storage_manager.go
@@ -80,7 +80,7 @@ func (this *StorageManager) Loop() error {
if int64(policy.Id) == publicPolicyId {
this.disableDefaultDB = policy.DisableDefaultDB
- this.writeTargets = serverconfigs.ParseWriteTargetsFromPolicy(policy.WriteTargets, policy.Type, policy.DisableDefaultDB)
+ this.writeTargets = serverconfigs.ResolveWriteTargetsByType(policy.Type, policy.DisableDefaultDB)
foundPolicy = true
}
}
@@ -185,6 +185,9 @@ func (this *StorageManager) WriteTargets() *serverconfigs.AccessLogWriteTargets
}
func (this *StorageManager) createStorage(storageType string, optionsJSON []byte) (StorageInterface, error) {
+ if serverconfigs.IsFileBasedStorageType(storageType) {
+ storageType = serverconfigs.AccessLogStorageTypeFile
+ }
switch storageType {
case serverconfigs.AccessLogStorageTypeFile:
var config = &serverconfigs.AccessLogFileStorageConfig{}
diff --git a/EdgeAPI/internal/clickhouse/logs_ingest_store.go b/EdgeAPI/internal/clickhouse/logs_ingest_store.go
index 7c2359a..dde2623 100644
--- a/EdgeAPI/internal/clickhouse/logs_ingest_store.go
+++ b/EdgeAPI/internal/clickhouse/logs_ingest_store.go
@@ -85,6 +85,10 @@ func (s *LogsIngestStore) List(ctx context.Context, f ListFilter) (rows []*LogsI
if f.Day == "" {
return nil, "", fmt.Errorf("clickhouse: day required")
}
+ dayNumber, err := normalizeDayNumber(f.Day)
+ if err != nil {
+ return nil, "", err
+ }
table := "logs_ingest"
if s.client.cfg.Database != "" && s.client.cfg.Database != "default" {
table = quoteIdent(s.client.cfg.Database) + "." + quoteIdent("logs_ingest")
@@ -92,7 +96,7 @@ func (s *LogsIngestStore) List(ctx context.Context, f ListFilter) (rows []*LogsI
table = quoteIdent(table)
}
- conditions := []string{"toDate(timestamp) = '" + escapeString(f.Day) + "'"}
+ conditions := []string{"toYYYYMMDD(timestamp) = " + strconv.Itoa(dayNumber)}
if f.HourFrom != "" {
if _, err := strconv.Atoi(f.HourFrom); err == nil {
conditions = append(conditions, "toHour(timestamp) >= "+f.HourFrom)
@@ -240,6 +244,22 @@ func escapeString(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
+func normalizeDayNumber(day string) (int, error) {
+ normalized := strings.TrimSpace(day)
+ if normalized == "" {
+ return 0, fmt.Errorf("clickhouse: day required")
+ }
+ normalized = strings.ReplaceAll(normalized, "-", "")
+ if len(normalized) != 8 {
+ return 0, fmt.Errorf("clickhouse: invalid day '%s'", day)
+ }
+ dayNumber, err := strconv.Atoi(normalized)
+ if err != nil {
+ return 0, fmt.Errorf("clickhouse: invalid day '%s'", day)
+ }
+ return dayNumber, nil
+}
+
func mapToLogsIngestRow(m map[string]interface{}) *LogsIngestRow {
r := &LogsIngestRow{}
u64 := func(key string) uint64 {
diff --git a/EdgeAPI/internal/clickhouse/ns_logs_ingest_store.go b/EdgeAPI/internal/clickhouse/ns_logs_ingest_store.go
new file mode 100644
index 0000000..a8a38c6
--- /dev/null
+++ b/EdgeAPI/internal/clickhouse/ns_logs_ingest_store.go
@@ -0,0 +1,375 @@
+package clickhouse
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
+)
+
+// NSLogsIngestRow 对应 dns_logs_ingest 表一行.
+type NSLogsIngestRow struct {
+ Timestamp time.Time
+ RequestId string
+ NodeId uint64
+ ClusterId uint64
+ DomainId uint64
+ RecordId uint64
+ RemoteAddr string
+ QuestionName string
+ QuestionType string
+ RecordName string
+ RecordType string
+ RecordValue string
+ Networking string
+ IsRecursive bool
+ Error string
+ NSRouteCodes []string
+ ContentJSON string
+}
+
+// NSListFilter DNS 日志查询过滤条件.
+type NSListFilter struct {
+ Day string
+ Size int64
+ Reverse bool
+ LastRequestId string
+ NSClusterId int64
+ NSNodeId int64
+ NSDomainId int64
+ NSRecordId int64
+ RecordType string
+ Keyword string
+}
+
+// NSLogsIngestStore DNS ClickHouse 查询封装.
+type NSLogsIngestStore struct {
+ client *Client
+}
+
+// NewNSLogsIngestStore 创建 NSLogsIngestStore.
+func NewNSLogsIngestStore() *NSLogsIngestStore {
+ return &NSLogsIngestStore{client: NewClient()}
+}
+
+// Client 返回底层 client.
+func (s *NSLogsIngestStore) Client() *Client {
+ return s.client
+}
+
+// List 列出 DNS 访问日志,返回列表、下一游标与是否有更多.
+func (s *NSLogsIngestStore) List(ctx context.Context, f NSListFilter) (rows []*NSLogsIngestRow, nextCursor string, hasMore bool, err error) {
+ if !s.client.IsConfigured() {
+ return nil, "", false, fmt.Errorf("clickhouse: not configured")
+ }
+ if f.Day == "" {
+ return nil, "", false, fmt.Errorf("clickhouse: day required")
+ }
+ dayNumber, err := normalizeDayNumber(f.Day)
+ if err != nil {
+ return nil, "", false, err
+ }
+
+ table := quoteIdent("dns_logs_ingest")
+ if s.client.cfg.Database != "" && s.client.cfg.Database != "default" {
+ table = quoteIdent(s.client.cfg.Database) + "." + quoteIdent("dns_logs_ingest")
+ }
+
+ conditions := []string{"toYYYYMMDD(timestamp) = " + strconv.Itoa(dayNumber)}
+ if f.NSClusterId > 0 {
+ conditions = append(conditions, "cluster_id = "+strconv.FormatInt(f.NSClusterId, 10))
+ }
+ if f.NSNodeId > 0 {
+ conditions = append(conditions, "node_id = "+strconv.FormatInt(f.NSNodeId, 10))
+ }
+ if f.NSDomainId > 0 {
+ conditions = append(conditions, "domain_id = "+strconv.FormatInt(f.NSDomainId, 10))
+ }
+ if f.NSRecordId > 0 {
+ conditions = append(conditions, "record_id = "+strconv.FormatInt(f.NSRecordId, 10))
+ }
+ if f.RecordType != "" {
+ conditions = append(conditions, "question_type = '"+escapeString(f.RecordType)+"'")
+ }
+ if f.Keyword != "" {
+ keyword := escapeString(f.Keyword)
+ conditions = append(conditions, fmt.Sprintf("(remote_addr LIKE '%%%s%%' OR question_name LIKE '%%%s%%' OR record_value LIKE '%%%s%%' OR error LIKE '%%%s%%')", keyword, keyword, keyword, keyword))
+ }
+
+ // 游标分页:reverse=false 查更旧,reverse=true 查更新。
+ if f.LastRequestId != "" {
+ if f.Reverse {
+ conditions = append(conditions, "request_id > '"+escapeString(f.LastRequestId)+"'")
+ } else {
+ conditions = append(conditions, "request_id < '"+escapeString(f.LastRequestId)+"'")
+ }
+ }
+
+ orderDir := "DESC"
+ if f.Reverse {
+ orderDir = "ASC"
+ }
+
+ limit := f.Size
+ if limit <= 0 {
+ limit = 20
+ }
+ if limit > 1000 {
+ limit = 1000
+ }
+
+ query := fmt.Sprintf(
+ "SELECT timestamp, request_id, node_id, cluster_id, domain_id, record_id, remote_addr, question_name, question_type, record_name, record_type, record_value, networking, is_recursive, error, ns_route_codes, content_json FROM %s WHERE %s ORDER BY timestamp %s, request_id %s LIMIT %d",
+ table,
+ strings.Join(conditions, " AND "),
+ orderDir,
+ orderDir,
+ limit+1,
+ )
+
+ var rawRows []map[string]interface{}
+ if err = s.client.Query(ctx, query, &rawRows); err != nil {
+ return nil, "", false, err
+ }
+
+ rows = make([]*NSLogsIngestRow, 0, len(rawRows))
+ for _, rawRow := range rawRows {
+ row := mapToNSLogsIngestRow(rawRow)
+ if row != nil {
+ rows = append(rows, row)
+ }
+ }
+
+ hasMore = len(rows) > int(limit)
+ if hasMore {
+ nextCursor = rows[limit].RequestId
+ rows = rows[:limit]
+ } else if len(rows) > 0 {
+ nextCursor = rows[len(rows)-1].RequestId
+ }
+
+ if f.Reverse {
+ for left, right := 0, len(rows)-1; left < right; left, right = left+1, right-1 {
+ rows[left], rows[right] = rows[right], rows[left]
+ }
+ if len(rows) > 0 {
+ nextCursor = rows[0].RequestId
+ }
+ }
+
+ return rows, nextCursor, hasMore, nil
+}
+
+// FindByRequestId 按 request_id 查单条 DNS 日志.
+func (s *NSLogsIngestStore) FindByRequestId(ctx context.Context, requestId string) (*NSLogsIngestRow, error) {
+ if !s.client.IsConfigured() {
+ return nil, fmt.Errorf("clickhouse: not configured")
+ }
+ if requestId == "" {
+ return nil, nil
+ }
+
+ table := quoteIdent("dns_logs_ingest")
+ if s.client.cfg.Database != "" && s.client.cfg.Database != "default" {
+ table = quoteIdent(s.client.cfg.Database) + "." + quoteIdent("dns_logs_ingest")
+ }
+
+ query := fmt.Sprintf(
+ "SELECT timestamp, request_id, node_id, cluster_id, domain_id, record_id, remote_addr, question_name, question_type, record_name, record_type, record_value, networking, is_recursive, error, ns_route_codes, content_json FROM %s WHERE request_id = '%s' LIMIT 1",
+ table,
+ escapeString(requestId),
+ )
+
+ var rawRows []map[string]interface{}
+ if err := s.client.Query(ctx, query, &rawRows); err != nil {
+ return nil, err
+ }
+ if len(rawRows) == 0 {
+ return nil, nil
+ }
+ return mapToNSLogsIngestRow(rawRows[0]), nil
+}
+
+func mapToNSLogsIngestRow(row map[string]interface{}) *NSLogsIngestRow {
+ result := &NSLogsIngestRow{}
+
+ u64 := func(key string) uint64 {
+ value, ok := row[key]
+ if !ok || value == nil {
+ return 0
+ }
+ switch typed := value.(type) {
+ case float64:
+ return uint64(typed)
+ case string:
+ number, _ := strconv.ParseUint(typed, 10, 64)
+ return number
+ case json.Number:
+ number, _ := typed.Int64()
+ return uint64(number)
+ case int64:
+ return uint64(typed)
+ case uint64:
+ return typed
+ }
+ return 0
+ }
+
+ str := func(key string) string {
+ value, ok := row[key]
+ if !ok || value == nil {
+ return ""
+ }
+ switch typed := value.(type) {
+ case string:
+ return typed
+ case json.Number:
+ return typed.String()
+ default:
+ return fmt.Sprintf("%v", typed)
+ }
+ }
+
+ ts := func(key string) time.Time {
+ value, ok := row[key]
+ if !ok || value == nil {
+ return time.Time{}
+ }
+ switch typed := value.(type) {
+ case string:
+ if typed == "" {
+ return time.Time{}
+ }
+ layouts := []string{
+ "2006-01-02 15:04:05",
+ time.RFC3339,
+ "2006-01-02T15:04:05",
+ }
+ for _, layout := range layouts {
+ if parsed, err := time.Parse(layout, typed); err == nil {
+ return parsed
+ }
+ }
+ case float64:
+ return time.Unix(int64(typed), 0)
+ case json.Number:
+ number, _ := typed.Int64()
+ return time.Unix(number, 0)
+ }
+ return time.Time{}
+ }
+
+ boolValue := func(key string) bool {
+ value, ok := row[key]
+ if !ok || value == nil {
+ return false
+ }
+ switch typed := value.(type) {
+ case bool:
+ return typed
+ case float64:
+ return typed > 0
+ case int64:
+ return typed > 0
+ case uint64:
+ return typed > 0
+ case string:
+ switch strings.ToLower(strings.TrimSpace(typed)) {
+ case "1", "true", "yes":
+ return true
+ }
+ }
+ return false
+ }
+
+ parseStringArray := func(key string) []string {
+ value, ok := row[key]
+ if !ok || value == nil {
+ return nil
+ }
+ switch typed := value.(type) {
+ case []string:
+ return typed
+ case []interface{}:
+ result := make([]string, 0, len(typed))
+ for _, one := range typed {
+ if one == nil {
+ continue
+ }
+ result = append(result, fmt.Sprintf("%v", one))
+ }
+ return result
+ case string:
+ if typed == "" {
+ return nil
+ }
+ var result []string
+ if json.Unmarshal([]byte(typed), &result) == nil {
+ return result
+ }
+ return []string{typed}
+ }
+ return nil
+ }
+
+ result.Timestamp = ts("timestamp")
+ result.RequestId = str("request_id")
+ result.NodeId = u64("node_id")
+ result.ClusterId = u64("cluster_id")
+ result.DomainId = u64("domain_id")
+ result.RecordId = u64("record_id")
+ result.RemoteAddr = str("remote_addr")
+ result.QuestionName = str("question_name")
+ result.QuestionType = str("question_type")
+ result.RecordName = str("record_name")
+ result.RecordType = str("record_type")
+ result.RecordValue = str("record_value")
+ result.Networking = str("networking")
+ result.IsRecursive = boolValue("is_recursive")
+ result.Error = str("error")
+ result.NSRouteCodes = parseStringArray("ns_route_codes")
+ result.ContentJSON = str("content_json")
+ return result
+}
+
+// NSRowToPB 将 ClickHouse 行转换为 pb.NSAccessLog.
+func NSRowToPB(row *NSLogsIngestRow) *pb.NSAccessLog {
+ if row == nil {
+ return nil
+ }
+
+ log := &pb.NSAccessLog{
+ NsNodeId: int64(row.NodeId),
+ NsDomainId: int64(row.DomainId),
+ NsRecordId: int64(row.RecordId),
+ NsRouteCodes: row.NSRouteCodes,
+ RemoteAddr: row.RemoteAddr,
+ QuestionName: row.QuestionName,
+ QuestionType: row.QuestionType,
+ RecordName: row.RecordName,
+ RecordType: row.RecordType,
+ RecordValue: row.RecordValue,
+ Networking: row.Networking,
+ Timestamp: row.Timestamp.Unix(),
+ RequestId: row.RequestId,
+ Error: row.Error,
+ IsRecursive: row.IsRecursive,
+ }
+ if !row.Timestamp.IsZero() {
+ log.TimeLocal = row.Timestamp.Format("2/Jan/2006:15:04:05 -0700")
+ }
+
+ if row.ContentJSON != "" {
+ contentLog := &pb.NSAccessLog{}
+ if json.Unmarshal([]byte(row.ContentJSON), contentLog) == nil {
+ contentLog.RequestId = row.RequestId
+ return contentLog
+ }
+ }
+
+ return log
+}
diff --git a/EdgeAPI/internal/db/models/http_access_log_policy_dao.go b/EdgeAPI/internal/db/models/http_access_log_policy_dao.go
index 2bf011a..57ab440 100644
--- a/EdgeAPI/internal/db/models/http_access_log_policy_dao.go
+++ b/EdgeAPI/internal/db/models/http_access_log_policy_dao.go
@@ -108,6 +108,7 @@ func (this *HTTPAccessLogPolicyDAO) FindAllEnabledAndOnPolicies(tx *dbs.Tx) (res
// CreatePolicy 创建策略
func (this *HTTPAccessLogPolicyDAO) CreatePolicy(tx *dbs.Tx, name string, policyType string, optionsJSON []byte, condsJSON []byte, isPublic bool, firewallOnly bool, disableDefaultDB bool, writeTargetsJSON []byte) (policyId int64, err error) {
+ _ = writeTargetsJSON
var op = NewHTTPAccessLogPolicyOperator()
op.Name = name
op.Type = policyType
@@ -121,15 +122,14 @@ func (this *HTTPAccessLogPolicyDAO) CreatePolicy(tx *dbs.Tx, name string, policy
op.IsOn = true
op.FirewallOnly = firewallOnly
op.DisableDefaultDB = disableDefaultDB
- if len(writeTargetsJSON) > 0 {
- op.WriteTargets = writeTargetsJSON
- }
+ op.WriteTargets = dbs.SQL("NULL")
op.State = HTTPAccessLogPolicyStateEnabled
return this.SaveInt64(tx, op)
}
// UpdatePolicy 修改策略
func (this *HTTPAccessLogPolicyDAO) UpdatePolicy(tx *dbs.Tx, policyId int64, name string, policyType string, optionsJSON []byte, condsJSON []byte, isPublic bool, firewallOnly bool, disableDefaultDB bool, writeTargetsJSON []byte, isOn bool) error {
+ _ = writeTargetsJSON
if policyId <= 0 {
return errors.New("invalid policyId")
}
@@ -167,9 +167,7 @@ func (this *HTTPAccessLogPolicyDAO) UpdatePolicy(tx *dbs.Tx, policyId int64, nam
op.IsPublic = isPublic
op.FirewallOnly = firewallOnly
op.DisableDefaultDB = disableDefaultDB
- if len(writeTargetsJSON) > 0 {
- op.WriteTargets = writeTargetsJSON
- }
+ op.WriteTargets = dbs.SQL("NULL")
op.IsOn = isOn
return this.Save(tx, op)
}
diff --git a/EdgeAPI/internal/db/models/http_access_log_policy_utils.go b/EdgeAPI/internal/db/models/http_access_log_policy_utils.go
new file mode 100644
index 0000000..cfa151e
--- /dev/null
+++ b/EdgeAPI/internal/db/models/http_access_log_policy_utils.go
@@ -0,0 +1,22 @@
+package models
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
+)
+
+// ParseHTTPAccessLogPolicyFilePath 提取访问日志策略中的文件路径(所有 file* 类型有效)。
+func ParseHTTPAccessLogPolicyFilePath(policy *HTTPAccessLogPolicy) string {
+ if policy == nil || !serverconfigs.IsFileBasedStorageType(policy.Type) || len(policy.Options) == 0 {
+ return ""
+ }
+
+ config := &serverconfigs.AccessLogFileStorageConfig{}
+ if err := json.Unmarshal(policy.Options, config); err != nil {
+ return ""
+ }
+
+ return strings.TrimSpace(config.Path)
+}
diff --git a/EdgeAPI/internal/db/models/node_dao.go b/EdgeAPI/internal/db/models/node_dao.go
index e052e7d..7805a72 100644
--- a/EdgeAPI/internal/db/models/node_dao.go
+++ b/EdgeAPI/internal/db/models/node_dao.go
@@ -1174,7 +1174,8 @@ func (this *NodeDAO) ComposeNodeConfig(tx *dbs.Tx, nodeId int64, dataMap *shared
if publicPolicyId > 0 {
publicPolicy, _ := SharedHTTPAccessLogPolicyDAO.FindEnabledHTTPAccessLogPolicy(tx, publicPolicyId)
if publicPolicy != nil {
- config.GlobalServerConfig.HTTPAccessLog.WriteTargets = serverconfigs.ParseWriteTargetsFromPolicy(publicPolicy.WriteTargets, publicPolicy.Type, publicPolicy.DisableDefaultDB)
+ config.GlobalServerConfig.HTTPAccessLog.WriteTargets = serverconfigs.ResolveWriteTargetsByType(publicPolicy.Type, publicPolicy.DisableDefaultDB)
+ config.GlobalServerConfig.HTTPAccessLog.FilePath = ParseHTTPAccessLogPolicyFilePath(publicPolicy)
}
}
}
diff --git a/EdgeAPI/internal/db/models/ns_node_dao_plus.go b/EdgeAPI/internal/db/models/ns_node_dao_plus.go
index 65e6145..0e59852 100644
--- a/EdgeAPI/internal/db/models/ns_node_dao_plus.go
+++ b/EdgeAPI/internal/db/models/ns_node_dao_plus.go
@@ -472,6 +472,16 @@ func (this *NSNodeDAO) ComposeNodeConfig(tx *dbs.Tx, nodeId int64) (*dnsconfigs.
}
}
}
+ {
+ publicPolicyId, _ := SharedHTTPAccessLogPolicyDAO.FindCurrentPublicPolicyId(tx)
+ if publicPolicyId > 0 {
+ publicPolicy, _ := SharedHTTPAccessLogPolicyDAO.FindEnabledHTTPAccessLogPolicy(tx, publicPolicyId)
+ if publicPolicy != nil {
+ config.AccessLogWriteTargets = serverconfigs.ResolveWriteTargetsByType(publicPolicy.Type, publicPolicy.DisableDefaultDB)
+ config.AccessLogFilePath = ParseHTTPAccessLogPolicyFilePath(publicPolicy)
+ }
+ }
+ }
// 递归DNS配置
if IsNotNull(cluster.Recursion) {
diff --git a/EdgeAPI/internal/rpc/services/nameservers/service_ns_access_log.go b/EdgeAPI/internal/rpc/services/nameservers/service_ns_access_log.go
index 51536a0..b4b17c5 100644
--- a/EdgeAPI/internal/rpc/services/nameservers/service_ns_access_log.go
+++ b/EdgeAPI/internal/rpc/services/nameservers/service_ns_access_log.go
@@ -4,6 +4,7 @@ package nameservers
import (
"context"
+ "github.com/TeaOSLab/EdgeAPI/internal/clickhouse"
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
"github.com/TeaOSLab/EdgeAPI/internal/db/models/nameservers"
"github.com/TeaOSLab/EdgeAPI/internal/rpc/services"
@@ -29,11 +30,13 @@ func (this *NSAccessLogService) CreateNSAccessLogs(ctx context.Context, req *pb.
return &pb.CreateNSAccessLogsResponse{}, nil
}
- var tx = this.NullTx()
+ if this.canWriteNSAccessLogsToDB() {
+ var tx = this.NullTx()
- err = models.SharedNSAccessLogDAO.CreateNSAccessLogs(tx, req.NsAccessLogs)
- if err != nil {
- return nil, err
+ err = models.SharedNSAccessLogDAO.CreateNSAccessLogs(tx, req.NsAccessLogs)
+ if err != nil {
+ return nil, err
+ }
}
return &pb.CreateNSAccessLogsResponse{}, nil
@@ -54,6 +57,34 @@ func (this *NSAccessLogService) ListNSAccessLogs(ctx context.Context, req *pb.Li
// TODO 检查权限
}
+ store := clickhouse.NewNSLogsIngestStore()
+ canReadFromClickHouse := this.shouldReadNSAccessLogsFromClickHouse() && store.Client().IsConfigured() && req.Day != ""
+ canReadFromMySQL := this.shouldReadNSAccessLogsFromMySQL()
+ if canReadFromClickHouse {
+ resp, listErr := this.listNSAccessLogsFromClickHouse(ctx, store, req)
+ if listErr == nil && resp != nil {
+ return resp, nil
+ }
+ if !canReadFromMySQL {
+ if listErr != nil {
+ return nil, listErr
+ }
+ return &pb.ListNSAccessLogsResponse{
+ NsAccessLogs: []*pb.NSAccessLog{},
+ HasMore: false,
+ RequestId: "",
+ }, nil
+ }
+ }
+
+ if !canReadFromMySQL {
+ return &pb.ListNSAccessLogsResponse{
+ NsAccessLogs: []*pb.NSAccessLog{},
+ HasMore: false,
+ RequestId: "",
+ }, nil
+ }
+
accessLogs, requestId, hasMore, err := models.SharedNSAccessLogDAO.ListAccessLogs(tx, req.RequestId, req.Size, req.Day, req.NsClusterId, req.NsNodeId, req.NsDomainId, req.NsRecordId, req.RecordType, req.Keyword, req.Reverse)
if err != nil {
return nil, err
@@ -67,23 +98,9 @@ func (this *NSAccessLogService) ListNSAccessLogs(ctx context.Context, req *pb.Li
}
// 线路
- if len(a.NsRouteCodes) > 0 {
- for _, routeCode := range a.NsRouteCodes {
- route, err := nameservers.SharedNSRouteDAO.FindEnabledRouteWithCode(nil, routeCode)
- if err != nil {
- return nil, err
- }
- if route != nil {
- a.NsRoutes = append(a.NsRoutes, &pb.NSRoute{
- Id: types.Int64(route.Id),
- IsOn: route.IsOn,
- Name: route.Name,
- Code: routeCode,
- NsCluster: nil,
- NsDomain: nil,
- })
- }
- }
+ err = this.fillNSRoutes(a)
+ if err != nil {
+ return nil, err
}
result = append(result, a)
@@ -104,6 +121,31 @@ func (this *NSAccessLogService) FindNSAccessLog(ctx context.Context, req *pb.Fin
return nil, err
}
+ store := clickhouse.NewNSLogsIngestStore()
+ canReadFromClickHouse := this.shouldReadNSAccessLogsFromClickHouse() && store.Client().IsConfigured()
+ canReadFromMySQL := this.shouldReadNSAccessLogsFromMySQL()
+ if canReadFromClickHouse {
+ row, findErr := store.FindByRequestId(ctx, req.RequestId)
+ if findErr != nil {
+ if !canReadFromMySQL {
+ return nil, findErr
+ }
+ } else if row != nil {
+ a := clickhouse.NSRowToPB(row)
+ if a != nil {
+ err = this.fillNSRoutes(a)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return &pb.FindNSAccessLogResponse{NsAccessLog: a}, nil
+ }
+ }
+
+ if !canReadFromMySQL {
+ return &pb.FindNSAccessLogResponse{NsAccessLog: nil}, nil
+ }
+
var tx = this.NullTx()
accessLog, err := models.SharedNSAccessLogDAO.FindAccessLogWithRequestId(tx, req.RequestId)
@@ -123,5 +165,70 @@ func (this *NSAccessLogService) FindNSAccessLog(ctx context.Context, req *pb.Fin
if err != nil {
return nil, err
}
+ err = this.fillNSRoutes(a)
+ if err != nil {
+ return nil, err
+ }
return &pb.FindNSAccessLogResponse{NsAccessLog: a}, nil
}
+
+func (this *NSAccessLogService) listNSAccessLogsFromClickHouse(ctx context.Context, store *clickhouse.NSLogsIngestStore, req *pb.ListNSAccessLogsRequest) (*pb.ListNSAccessLogsResponse, error) {
+ rows, nextCursor, hasMore, err := store.List(ctx, clickhouse.NSListFilter{
+ Day: req.Day,
+ Size: req.Size,
+ Reverse: req.Reverse,
+ LastRequestId: req.RequestId,
+ NSClusterId: req.NsClusterId,
+ NSNodeId: req.NsNodeId,
+ NSDomainId: req.NsDomainId,
+ NSRecordId: req.NsRecordId,
+ RecordType: req.RecordType,
+ Keyword: req.Keyword,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ result := make([]*pb.NSAccessLog, 0, len(rows))
+ for _, row := range rows {
+ a := clickhouse.NSRowToPB(row)
+ if a == nil {
+ continue
+ }
+ err = this.fillNSRoutes(a)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, a)
+ }
+
+ return &pb.ListNSAccessLogsResponse{
+ NsAccessLogs: result,
+ HasMore: hasMore,
+ RequestId: nextCursor,
+ }, nil
+}
+
+func (this *NSAccessLogService) fillNSRoutes(accessLog *pb.NSAccessLog) error {
+ if accessLog == nil || len(accessLog.NsRouteCodes) == 0 {
+ return nil
+ }
+
+ for _, routeCode := range accessLog.NsRouteCodes {
+ route, err := nameservers.SharedNSRouteDAO.FindEnabledRouteWithCode(nil, routeCode)
+ if err != nil {
+ return err
+ }
+ if route != nil {
+ accessLog.NsRoutes = append(accessLog.NsRoutes, &pb.NSRoute{
+ Id: types.Int64(route.Id),
+ IsOn: route.IsOn,
+ Name: route.Name,
+ Code: routeCode,
+ NsCluster: nil,
+ NsDomain: nil,
+ })
+ }
+ }
+ return nil
+}
diff --git a/EdgeAPI/internal/rpc/services/nameservers/service_ns_access_log_ext_plus.go b/EdgeAPI/internal/rpc/services/nameservers/service_ns_access_log_ext_plus.go
new file mode 100644
index 0000000..fbe6774
--- /dev/null
+++ b/EdgeAPI/internal/rpc/services/nameservers/service_ns_access_log_ext_plus.go
@@ -0,0 +1,17 @@
+//go:build plus
+
+package nameservers
+
+import "github.com/TeaOSLab/EdgeAPI/internal/accesslogs"
+
+func (this *NSAccessLogService) canWriteNSAccessLogsToDB() bool {
+ return accesslogs.SharedStorageManager.WriteMySQL()
+}
+
+func (this *NSAccessLogService) shouldReadNSAccessLogsFromClickHouse() bool {
+ return accesslogs.SharedStorageManager.WriteClickHouse()
+}
+
+func (this *NSAccessLogService) shouldReadNSAccessLogsFromMySQL() bool {
+ return accesslogs.SharedStorageManager.WriteMySQL()
+}
diff --git a/EdgeAPI/internal/rpc/services/service_http_access_log_policy_plus.go b/EdgeAPI/internal/rpc/services/service_http_access_log_policy_plus.go
index 1823c26..ab37ae6 100644
--- a/EdgeAPI/internal/rpc/services/service_http_access_log_policy_plus.go
+++ b/EdgeAPI/internal/rpc/services/service_http_access_log_policy_plus.go
@@ -8,12 +8,27 @@ import (
"github.com/TeaOSLab/EdgeAPI/internal/accesslogs"
"github.com/TeaOSLab/EdgeAPI/internal/db/models"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
+ "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
)
type HTTPAccessLogPolicyService struct {
BaseService
}
+func (this *HTTPAccessLogPolicyService) normalizeStorageTypeAndTargets(policyType string, writeTargetsJSON []byte, disableDefaultDB bool) (string, []byte) {
+ _ = writeTargetsJSON
+ _ = disableDefaultDB
+
+ // 兼容旧前端/缓存可能传来的历史类型编码
+ switch policyType {
+ case "clickhouse":
+ policyType = serverconfigs.AccessLogStorageTypeFileClickhouse
+ case "mysql_clickhouse":
+ policyType = serverconfigs.AccessLogStorageTypeFileMySQLClickhouse
+ }
+ return policyType, nil
+}
+
// CountAllHTTPAccessLogPolicies 计算访问日志策略数量
func (this *HTTPAccessLogPolicyService) CountAllHTTPAccessLogPolicies(ctx context.Context, req *pb.CountAllHTTPAccessLogPoliciesRequest) (*pb.RPCCountResponse, error) {
_, err := this.ValidateAdmin(ctx)
@@ -53,7 +68,7 @@ func (this *HTTPAccessLogPolicyService) ListHTTPAccessLogPolicies(ctx context.Co
IsPublic: policy.IsPublic,
FirewallOnly: policy.FirewallOnly == 1,
DisableDefaultDB: policy.DisableDefaultDB,
- WriteTargetsJSON: policy.WriteTargets,
+ WriteTargetsJSON: nil,
})
}
return &pb.ListHTTPAccessLogPoliciesResponse{HttpAccessLogPolicies: pbPolicies}, nil
@@ -76,8 +91,10 @@ func (this *HTTPAccessLogPolicyService) CreateHTTPAccessLogPolicy(ctx context.Co
}
}
+ policyType, writeTargetsJSON := this.normalizeStorageTypeAndTargets(req.Type, req.WriteTargetsJSON, req.DisableDefaultDB)
+
// 创建
- policyId, err := models.SharedHTTPAccessLogPolicyDAO.CreatePolicy(tx, req.Name, req.Type, req.OptionsJSON, req.CondsJSON, req.IsPublic, req.FirewallOnly, req.DisableDefaultDB, req.WriteTargetsJSON)
+ policyId, err := models.SharedHTTPAccessLogPolicyDAO.CreatePolicy(tx, req.Name, policyType, req.OptionsJSON, req.CondsJSON, req.IsPublic, req.FirewallOnly, req.DisableDefaultDB, writeTargetsJSON)
if err != nil {
return nil, err
}
@@ -101,8 +118,10 @@ func (this *HTTPAccessLogPolicyService) UpdateHTTPAccessLogPolicy(ctx context.Co
}
}
+ policyType, writeTargetsJSON := this.normalizeStorageTypeAndTargets(req.Type, req.WriteTargetsJSON, req.DisableDefaultDB)
+
// 保存修改
- err = models.SharedHTTPAccessLogPolicyDAO.UpdatePolicy(tx, req.HttpAccessLogPolicyId, req.Name, req.Type, req.OptionsJSON, req.CondsJSON, req.IsPublic, req.FirewallOnly, req.DisableDefaultDB, req.WriteTargetsJSON, req.IsOn)
+ err = models.SharedHTTPAccessLogPolicyDAO.UpdatePolicy(tx, req.HttpAccessLogPolicyId, req.Name, policyType, req.OptionsJSON, req.CondsJSON, req.IsPublic, req.FirewallOnly, req.DisableDefaultDB, writeTargetsJSON, req.IsOn)
if err != nil {
return nil, err
}
@@ -134,7 +153,7 @@ func (this *HTTPAccessLogPolicyService) FindHTTPAccessLogPolicy(ctx context.Cont
IsPublic: policy.IsPublic,
FirewallOnly: policy.FirewallOnly == 1,
DisableDefaultDB: policy.DisableDefaultDB,
- WriteTargetsJSON: policy.WriteTargets,
+ WriteTargetsJSON: nil,
}}, nil
}
diff --git a/EdgeAdmin/build/build-all-plus.sh b/EdgeAdmin/build/build-all-plus.sh
index 7d4c8f5..2c3b9b9 100644
--- a/EdgeAdmin/build/build-all-plus.sh
+++ b/EdgeAdmin/build/build-all-plus.sh
@@ -18,7 +18,7 @@ echo "build all edge-admin"
echo "=============================="
./build.sh linux amd64 plus
#./build.sh linux 386 plus
-./build.sh linux arm64 plus
+#./build.sh linux arm64 plus
#./build.sh linux mips64 plus
#./build.sh linux mips64le plus
#./build.sh darwin amd64 plus
@@ -26,4 +26,3 @@ echo "=============================="
-
diff --git a/EdgeAdmin/build/build-all.sh b/EdgeAdmin/build/build-all.sh
index ad11dad..26019fc 100644
--- a/EdgeAdmin/build/build-all.sh
+++ b/EdgeAdmin/build/build-all.sh
@@ -14,8 +14,8 @@ fi
./build.sh linux amd64
./build.sh linux 386
-./build.sh linux arm64
+#./build.sh linux arm64
./build.sh linux mips64
./build.sh linux mips64le
./build.sh darwin amd64
-./build.sh darwin arm64
+#./build.sh darwin arm64
diff --git a/EdgeAdmin/internal/web/actions/default/servers/accesslogs/createPopup.go b/EdgeAdmin/internal/web/actions/default/servers/accesslogs/createPopup.go
index fd53d4a..228c5ef 100644
--- a/EdgeAdmin/internal/web/actions/default/servers/accesslogs/createPopup.go
+++ b/EdgeAdmin/internal/web/actions/default/servers/accesslogs/createPopup.go
@@ -87,17 +87,20 @@ func (this *CreatePopupAction) RunPost(params struct {
Field("type", params.Type).
Require("请选择存储类型")
- baseType, writeTargets := serverconfigs.ParseStorageTypeAndWriteTargets(params.Type)
- if writeTargets == nil {
- writeTargets = &serverconfigs.AccessLogWriteTargets{File: true, MySQL: true}
+ baseType, _ := serverconfigs.ParseStorageTypeAndWriteTargets(params.Type)
+ storedType := baseType
+ if serverconfigs.IsFileBasedStorageType(params.Type) {
+ storedType = params.Type
}
var options any = nil
switch baseType {
case serverconfigs.AccessLogStorageTypeFile:
- params.Must.
- Field("filePath", params.FilePath).
- Require("请输入日志文件路径")
+ if !serverconfigs.IsFileBasedStorageType(params.Type) || (params.Type != serverconfigs.AccessLogStorageTypeFileClickhouse && params.Type != serverconfigs.AccessLogStorageTypeFileMySQLClickhouse) {
+ params.Must.
+ Field("filePath", params.FilePath).
+ Require("请输入日志文件路径")
+ }
var storage = new(serverconfigs.AccessLogFileStorageConfig)
storage.Path = params.FilePath
@@ -175,21 +178,15 @@ func (this *CreatePopupAction) RunPost(params struct {
this.ErrorPage(err)
return
}
- writeTargetsMap := map[string]bool{
- "file": writeTargets.File,
- "mysql": writeTargets.MySQL,
- "clickhouse": writeTargets.ClickHouse,
- }
- writeTargetsJSON, _ := json.Marshal(writeTargetsMap)
createResp, err := this.RPC().HTTPAccessLogPolicyRPC().CreateHTTPAccessLogPolicy(this.AdminContext(), &pb.CreateHTTPAccessLogPolicyRequest{
Name: params.Name,
- Type: baseType,
+ Type: storedType,
OptionsJSON: optionsJSON,
CondsJSON: nil, // TODO
IsPublic: params.IsPublic,
FirewallOnly: params.FirewallOnly,
DisableDefaultDB: params.DisableDefaultDB,
- WriteTargetsJSON: writeTargetsJSON,
+ WriteTargetsJSON: nil,
})
if err != nil {
this.ErrorPage(err)
diff --git a/EdgeAdmin/internal/web/actions/default/servers/accesslogs/index.go b/EdgeAdmin/internal/web/actions/default/servers/accesslogs/index.go
index 4cded29..d71a87a 100644
--- a/EdgeAdmin/internal/web/actions/default/servers/accesslogs/index.go
+++ b/EdgeAdmin/internal/web/actions/default/servers/accesslogs/index.go
@@ -46,11 +46,7 @@ func (this *IndexAction) RunGet(params struct{}) {
return
}
}
- writeTargets := serverconfigs.ParseWriteTargetsFromPolicy(policy.WriteTargetsJSON, policy.Type, policy.DisableDefaultDB)
- typeDisplay := serverconfigs.ComposeStorageTypeDisplay(policy.Type, writeTargets)
- if typeDisplay == "" {
- typeDisplay = policy.Type
- }
+ typeDisplay := policy.Type
policyMaps = append(policyMaps, maps.Map{
"id": policy.Id,
"name": policy.Name,
diff --git a/EdgeAdmin/internal/web/actions/default/servers/accesslogs/policyutils/utils.go b/EdgeAdmin/internal/web/actions/default/servers/accesslogs/policyutils/utils.go
index bf27397..69b74ba 100644
--- a/EdgeAdmin/internal/web/actions/default/servers/accesslogs/policyutils/utils.go
+++ b/EdgeAdmin/internal/web/actions/default/servers/accesslogs/policyutils/utils.go
@@ -36,11 +36,7 @@ func InitPolicy(parent *actionutils.ParentAction, policyId int64) error {
}
}
- writeTargets := serverconfigs.ParseWriteTargetsFromPolicy(policy.WriteTargetsJSON, policy.Type, policy.DisableDefaultDB)
- typeDisplay := serverconfigs.ComposeStorageTypeDisplay(policy.Type, writeTargets)
- if typeDisplay == "" {
- typeDisplay = policy.Type
- }
+ typeDisplay := policy.Type
parent.Data["policy"] = maps.Map{
"id": policy.Id,
diff --git a/EdgeAdmin/internal/web/actions/default/servers/accesslogs/update.go b/EdgeAdmin/internal/web/actions/default/servers/accesslogs/update.go
index 6a7ca06..fe6f192 100644
--- a/EdgeAdmin/internal/web/actions/default/servers/accesslogs/update.go
+++ b/EdgeAdmin/internal/web/actions/default/servers/accesslogs/update.go
@@ -106,21 +106,30 @@ func (this *UpdateAction) RunPost(params struct {
Field("type", params.Type).
Require("请选择存储类型")
- baseType, writeTargets := serverconfigs.ParseStorageTypeAndWriteTargets(params.Type)
- if writeTargets == nil {
- writeTargets = &serverconfigs.AccessLogWriteTargets{File: true, MySQL: true}
+ baseType, _ := serverconfigs.ParseStorageTypeAndWriteTargets(params.Type)
+ storedType := baseType
+ if serverconfigs.IsFileBasedStorageType(params.Type) {
+ storedType = params.Type
}
var options interface{} = nil
switch baseType {
case serverconfigs.AccessLogStorageTypeFile:
- params.Must.
- Field("filePath", params.FilePath).
- Require("请输入日志文件路径")
-
var storage = new(serverconfigs.AccessLogFileStorageConfig)
- storage.Path = params.FilePath
- storage.AutoCreate = params.FileAutoCreate
+ if params.Type == serverconfigs.AccessLogStorageTypeFileClickhouse || params.Type == serverconfigs.AccessLogStorageTypeFileMySQLClickhouse {
+ if len(policy.OptionsJSON) > 0 {
+ _ = json.Unmarshal(policy.OptionsJSON, storage)
+ }
+ if len(params.FilePath) > 0 {
+ storage.Path = params.FilePath
+ }
+ } else {
+ params.Must.
+ Field("filePath", params.FilePath).
+ Require("请输入日志文件路径")
+ storage.Path = params.FilePath
+ storage.AutoCreate = params.FileAutoCreate
+ }
options = storage
case serverconfigs.AccessLogStorageTypeES:
params.Must.
@@ -195,23 +204,17 @@ func (this *UpdateAction) RunPost(params struct {
this.ErrorPage(err)
return
}
- writeTargetsMap := map[string]bool{
- "file": writeTargets.File,
- "mysql": writeTargets.MySQL,
- "clickhouse": writeTargets.ClickHouse,
- }
- writeTargetsJSON, _ := json.Marshal(writeTargetsMap)
_, err = this.RPC().HTTPAccessLogPolicyRPC().UpdateHTTPAccessLogPolicy(this.AdminContext(), &pb.UpdateHTTPAccessLogPolicyRequest{
HttpAccessLogPolicyId: params.PolicyId,
Name: params.Name,
- Type: baseType,
+ Type: storedType,
OptionsJSON: optionsJSON,
CondsJSON: nil, // TODO
IsOn: params.IsOn,
IsPublic: params.IsPublic,
FirewallOnly: params.FirewallOnly,
DisableDefaultDB: params.DisableDefaultDB,
- WriteTargetsJSON: writeTargetsJSON,
+ WriteTargetsJSON: nil,
})
if err != nil {
this.ErrorPage(err)
diff --git a/EdgeAdmin/web/views/@default/servers/accesslogs/createPopup.html b/EdgeAdmin/web/views/@default/servers/accesslogs/createPopup.html
index 3653918..001b802 100644
--- a/EdgeAdmin/web/views/@default/servers/accesslogs/createPopup.html
+++ b/EdgeAdmin/web/views/@default/servers/accesslogs/createPopup.html
@@ -21,8 +21,8 @@
-
-
+
+
| 日志文件路径 * |
@@ -36,7 +36,9 @@
时:${hour}
分:${minute}
秒:${second}
- 年月日:${date},比如/var/log/web-access-${date}.log,此文件会在API节点上写入。
+ 年月日:${date},比如/var/log/web-access-${date}.log。
+ 当存储类型包含 ClickHouse 时,此文件会在节点侧写入,并由 Fluent Bit 采集后写入 ClickHouse。
+ 此文件会在API节点上写入。
|
@@ -51,6 +53,15 @@
+
+
+
+ | 日志文件路径 |
+
+
+ |
+
+
@@ -242,4 +253,4 @@
-
\ No newline at end of file
+
diff --git a/EdgeAdmin/web/views/@default/servers/accesslogs/update.html b/EdgeAdmin/web/views/@default/servers/accesslogs/update.html
index 0353a07..4e32dc5 100644
--- a/EdgeAdmin/web/views/@default/servers/accesslogs/update.html
+++ b/EdgeAdmin/web/views/@default/servers/accesslogs/update.html
@@ -22,8 +22,8 @@
-
-
+
+
| 日志文件路径 * |
@@ -37,7 +37,9 @@
时:${hour}
分:${minute}
秒:${second}
- 年月日:${date},比如/var/log/web-access-${date}.log,此文件会在API节点上写入。
+ 年月日:${date},比如/var/log/web-access-${date}.log。
+ 当存储类型包含 ClickHouse 时,此文件会在节点侧写入,并由 Fluent Bit 采集后写入 ClickHouse。
+ 此文件会在API节点上写入。
|
@@ -52,6 +54,15 @@
+
+
+
+ | 日志文件路径 |
+
+
+ |
+
+
@@ -247,4 +258,4 @@
-
\ No newline at end of file
+
diff --git a/EdgeCommon/pkg/dnsconfigs/ns_node_config.go b/EdgeCommon/pkg/dnsconfigs/ns_node_config.go
index 5427b47..64d4188 100644
--- a/EdgeCommon/pkg/dnsconfigs/ns_node_config.go
+++ b/EdgeCommon/pkg/dnsconfigs/ns_node_config.go
@@ -11,21 +11,23 @@ import (
)
type NSNodeConfig struct {
- Id int64 `yaml:"id" json:"id"`
- IsPlus bool `yaml:"isPlus" json:"isPlus"`
- NodeId string `yaml:"nodeId" json:"nodeId"`
- Secret string `yaml:"secret" json:"secret"`
- ClusterId int64 `yaml:"clusterId" json:"clusterId"`
- AccessLogRef *NSAccessLogRef `yaml:"accessLogRef" json:"accessLogRef"`
- RecursionConfig *NSRecursionConfig `yaml:"recursionConfig" json:"recursionConfig"`
- DDoSProtection *ddosconfigs.ProtectionConfig `yaml:"ddosProtection" json:"ddosProtection"`
- AllowedIPs []string `yaml:"allowedIPs" json:"allowedIPs"`
- TimeZone string `yaml:"timeZone" json:"timeZone"` // 自动设置时区
- Hosts []string `yaml:"hosts" json:"hosts"` // 主机名
- Email string `yaml:"email" json:"email"`
- SOA *NSSOAConfig `yaml:"soa" json:"soa"` // SOA配置
- SOASerial uint32 `yaml:"soaSerial" json:"soaSerial"`
- DetectAgents bool `yaml:"detectAgents" json:"detectAgents"` // 是否实时监测Agents
+ Id int64 `yaml:"id" json:"id"`
+ IsPlus bool `yaml:"isPlus" json:"isPlus"`
+ NodeId string `yaml:"nodeId" json:"nodeId"`
+ Secret string `yaml:"secret" json:"secret"`
+ ClusterId int64 `yaml:"clusterId" json:"clusterId"`
+ AccessLogRef *NSAccessLogRef `yaml:"accessLogRef" json:"accessLogRef"`
+ AccessLogWriteTargets *serverconfigs.AccessLogWriteTargets `yaml:"accessLogWriteTargets" json:"accessLogWriteTargets"`
+ AccessLogFilePath string `yaml:"accessLogFilePath" json:"accessLogFilePath"`
+ RecursionConfig *NSRecursionConfig `yaml:"recursionConfig" json:"recursionConfig"`
+ DDoSProtection *ddosconfigs.ProtectionConfig `yaml:"ddosProtection" json:"ddosProtection"`
+ AllowedIPs []string `yaml:"allowedIPs" json:"allowedIPs"`
+ TimeZone string `yaml:"timeZone" json:"timeZone"` // 自动设置时区
+ Hosts []string `yaml:"hosts" json:"hosts"` // 主机名
+ Email string `yaml:"email" json:"email"`
+ SOA *NSSOAConfig `yaml:"soa" json:"soa"` // SOA配置
+ SOASerial uint32 `yaml:"soaSerial" json:"soaSerial"`
+ DetectAgents bool `yaml:"detectAgents" json:"detectAgents"` // 是否实时监测Agents
TCP *serverconfigs.TCPProtocolConfig `yaml:"tcp" json:"tcp"` // TCP配置
TLS *serverconfigs.TLSProtocolConfig `yaml:"tls" json:"tls"` // TLS配置
diff --git a/EdgeCommon/pkg/serverconfigs/access_log_storages.go b/EdgeCommon/pkg/serverconfigs/access_log_storages.go
index 7bf6673..275f318 100644
--- a/EdgeCommon/pkg/serverconfigs/access_log_storages.go
+++ b/EdgeCommon/pkg/serverconfigs/access_log_storages.go
@@ -84,6 +84,33 @@ func ParseStorageTypeAndWriteTargets(selectedType string) (baseType string, writ
return baseType, writeTargets
}
+// ResolveWriteTargetsByType 仅根据策略类型与 disableDefaultDB 计算写入目标(不依赖 writeTargets 字段)
+func ResolveWriteTargetsByType(policyType string, disableDefaultDB bool) *AccessLogWriteTargets {
+ t := &AccessLogWriteTargets{}
+ switch policyType {
+ case AccessLogStorageTypeFileMySQL:
+ t.File = true
+ t.MySQL = true
+ case AccessLogStorageTypeFileClickhouse:
+ t.File = true
+ t.ClickHouse = true
+ case AccessLogStorageTypeFileMySQLClickhouse:
+ t.File = true
+ t.MySQL = true
+ t.ClickHouse = true
+ case AccessLogStorageTypeFile:
+ t.File = true
+ t.MySQL = !disableDefaultDB
+ default:
+ t.MySQL = !disableDefaultDB
+ }
+ if !t.File && !t.MySQL && !t.ClickHouse {
+ t.File = true
+ t.MySQL = true
+ }
+ return t
+}
+
// ComposeStorageTypeDisplay 根据策略的 Type + WriteTargets 得到下拉框显示用的类型 code(用于编辑页回显)
func ComposeStorageTypeDisplay(policyType string, writeTargets *AccessLogWriteTargets) string {
if policyType != AccessLogStorageTypeFile {
diff --git a/EdgeCommon/pkg/serverconfigs/access_log_write_targets.go b/EdgeCommon/pkg/serverconfigs/access_log_write_targets.go
index 1e2b03b..be19ade 100644
--- a/EdgeCommon/pkg/serverconfigs/access_log_write_targets.go
+++ b/EdgeCommon/pkg/serverconfigs/access_log_write_targets.go
@@ -2,8 +2,6 @@
package serverconfigs
-import "encoding/json"
-
// AccessLogWriteTargets 访问日志写入目标(双写/单写:文件、MySQL、ClickHouse)
type AccessLogWriteTargets struct {
File bool `yaml:"file" json:"file"` // 写本地 JSON 文件(供 Fluent Bit → ClickHouse 或自用)
@@ -27,23 +25,8 @@ func (t *AccessLogWriteTargets) NeedWriteFile() bool {
return t.File
}
-// ParseWriteTargetsFromPolicy 从策略的 writeTargets JSON 与旧字段解析;无 writeTargets 时按 type + disableDefaultDB 推断
+// ParseWriteTargetsFromPolicy 兼容入口:当前统一按 type 推导写入目标,不再依赖 writeTargets 字段
func ParseWriteTargetsFromPolicy(writeTargetsJSON []byte, policyType string, disableDefaultDB bool) *AccessLogWriteTargets {
- if len(writeTargetsJSON) > 0 {
- var t AccessLogWriteTargets
- if err := json.Unmarshal(writeTargetsJSON, &t); err == nil {
- return &t
- }
- }
- // 兼容旧策略:type=file 视为写文件,!disableDefaultDB 视为写 MySQL
- t := &AccessLogWriteTargets{
- File: policyType == AccessLogStorageTypeFile,
- MySQL: !disableDefaultDB,
- ClickHouse: false,
- }
- if !t.File && !t.MySQL && !t.ClickHouse {
- t.File = true
- t.MySQL = true
- }
- return t
+ _ = writeTargetsJSON
+ return ResolveWriteTargetsByType(policyType, disableDefaultDB)
}
diff --git a/EdgeCommon/pkg/serverconfigs/global_server_config.go b/EdgeCommon/pkg/serverconfigs/global_server_config.go
index f2e23bd..3cb65e5 100644
--- a/EdgeCommon/pkg/serverconfigs/global_server_config.go
+++ b/EdgeCommon/pkg/serverconfigs/global_server_config.go
@@ -78,6 +78,7 @@ type GlobalServerConfig struct {
EnableCookies bool `yaml:"enableCookies" json:"enableCookies"` // 记录Cookie
EnableServerNotFound bool `yaml:"enableServerNotFound" json:"enableServerNotFound"` // 记录服务找不到的日志
WriteTargets *AccessLogWriteTargets `yaml:"writeTargets" json:"writeTargets"` // 写入目标:文件/MySQL/ClickHouse(双写/单写)
+ FilePath string `yaml:"filePath" json:"filePath"` // 公用日志策略文件路径(用于节点侧复用)
} `yaml:"httpAccessLog" json:"httpAccessLog"` // 访问日志配置
Stat struct {
diff --git a/EdgeDNS/build/build-all.sh b/EdgeDNS/build/build-all.sh
index bcc2e23..9ee68d4 100644
--- a/EdgeDNS/build/build-all.sh
+++ b/EdgeDNS/build/build-all.sh
@@ -2,8 +2,8 @@
./build.sh linux amd64
#./build.sh linux 386
-./build.sh linux arm64
+#./build.sh linux arm64
#./build.sh linux mips64
#./build.sh linux mips64le
#./build.sh darwin amd64
-#./build.sh darwin arm64
\ No newline at end of file
+#./build.sh darwin arm64
diff --git a/EdgeDNS/go.mod b/EdgeDNS/go.mod
index b857817..b470d94 100644
--- a/EdgeDNS/go.mod
+++ b/EdgeDNS/go.mod
@@ -24,6 +24,8 @@ require (
github.com/josharian/native v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
+ github.com/oschwald/geoip2-golang v1.13.0 // indirect
+ github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.13 // indirect
diff --git a/EdgeDNS/go.sum b/EdgeDNS/go.sum
index c2c985b..ce34f7c 100644
--- a/EdgeDNS/go.sum
+++ b/EdgeDNS/go.sum
@@ -1,15 +1,23 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/nftables v0.2.0 h1:PbJwaBmbVLzpeldoeUKGkE2RjstrjPKMl6oLrfEJ6/8=
github.com/google/nftables v0.2.0/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/iwind/TeaGo v0.0.0-20240128112714-6bcd0529d0ea h1:o0QCF6tMJ9E6OgU1c0L+rYDshKsTu7mEk+7KCGLbnpI=
github.com/iwind/TeaGo v0.0.0-20240128112714-6bcd0529d0ea/go.mod h1:Ng3xWekHSVy0E/6/jYqJ7Htydm/H+mWIl0AS+Eg3H2M=
github.com/iwind/gosock v0.0.0-20220505115348-f88412125a62 h1:HJH6RDheAY156DnIfJSD/bEvqyXzsZuE2gzs8PuUjoo=
@@ -30,6 +38,10 @@ github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
+github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
+github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
+github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
+github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
@@ -45,8 +57,9 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4=
github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0=
@@ -57,39 +70,43 @@ github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc h1:R83G5ikgLMxrB
github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
-golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
+go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
+go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
+go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
+go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
+go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
+go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
+go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
+go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
+go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
+golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
-golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
-golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
-golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
-golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
-golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
-golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
-golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240311173647-c811ad7063a7 h1:8EeVk1VKMD+GD/neyEHGmz7pFblqPjHoi+PGQIlLx2s=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240311173647-c811ad7063a7/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
-google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
-google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
+google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
-google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
-google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
diff --git a/EdgeDNS/internal/accesslogs/dns_file_writer.go b/EdgeDNS/internal/accesslogs/dns_file_writer.go
new file mode 100644
index 0000000..35b5500
--- /dev/null
+++ b/EdgeDNS/internal/accesslogs/dns_file_writer.go
@@ -0,0 +1,199 @@
+package accesslogs
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
+ "github.com/TeaOSLab/EdgeDNS/internal/remotelogs"
+)
+
+const (
+ defaultDNSLogDir = "/var/log/edge/edge-dns"
+ envDNSLogDir = "EDGE_DNS_LOG_DIR"
+)
+
+var (
+ sharedDNSFileWriter *DNSFileWriter
+ sharedDNSFileWriterOnce sync.Once
+)
+
+// SharedDNSFileWriter 返回 DNS 本地日志写入器(单例).
+func SharedDNSFileWriter() *DNSFileWriter {
+ sharedDNSFileWriterOnce.Do(func() {
+ sharedDNSFileWriter = NewDNSFileWriter()
+ })
+ return sharedDNSFileWriter
+}
+
+// DNSFileWriter 将 DNS 访问日志以 JSON Lines 写入本地文件,供 Fluent Bit 采集.
+type DNSFileWriter struct {
+ dir string
+ mu sync.Mutex
+ file *os.File
+ inited bool
+}
+
+// NewDNSFileWriter 创建 DNS 本地日志写入器.
+func NewDNSFileWriter() *DNSFileWriter {
+ logDir := resolveDefaultDNSLogDir()
+ return &DNSFileWriter{dir: logDir}
+}
+
+func resolveDefaultDNSLogDir() string {
+ logDir := strings.TrimSpace(os.Getenv(envDNSLogDir))
+ if logDir == "" {
+ return defaultDNSLogDir
+ }
+ return logDir
+}
+
+func resolveDNSDirFromPolicyPath(policyPath string) string {
+ policyPath = strings.TrimSpace(policyPath)
+ if policyPath == "" {
+ return ""
+ }
+
+ if strings.HasSuffix(policyPath, "/") || strings.HasSuffix(policyPath, "\\") {
+ return filepath.Clean(policyPath)
+ }
+
+ baseName := filepath.Base(policyPath)
+ if strings.Contains(baseName, ".") || strings.Contains(baseName, "${") {
+ return filepath.Clean(filepath.Dir(policyPath))
+ }
+
+ return filepath.Clean(policyPath)
+}
+
+// Dir 返回当前日志目录.
+func (w *DNSFileWriter) Dir() string {
+ return w.dir
+}
+
+// SetDirByPolicyPath 使用公用日志策略 path 更新目录,空值时回退到 EDGE_DNS_LOG_DIR/default。
+func (w *DNSFileWriter) SetDirByPolicyPath(policyPath string) {
+ dir := resolveDNSDirFromPolicyPath(policyPath)
+ w.SetDir(dir)
+}
+
+// SetDir 更新目录并重置文件句柄。
+func (w *DNSFileWriter) SetDir(dir string) {
+ if strings.TrimSpace(dir) == "" {
+ dir = resolveDefaultDNSLogDir()
+ }
+
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ if dir == w.dir {
+ return
+ }
+
+ if w.file != nil {
+ _ = w.file.Close()
+ w.file = nil
+ }
+ w.inited = false
+ w.dir = dir
+}
+
+// EnsureInit 在启动时预创建目录与 access.log.
+func (w *DNSFileWriter) EnsureInit() error {
+ if w.dir == "" {
+ return nil
+ }
+ return w.init()
+}
+
+func (w *DNSFileWriter) init() error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ if w.inited && w.file != nil {
+ return nil
+ }
+
+ if w.dir == "" {
+ return nil
+ }
+
+ if err := os.MkdirAll(w.dir, 0755); err != nil {
+ remotelogs.Error("DNS_ACCESS_LOG_FILE", "mkdir log dir failed: "+err.Error())
+ return err
+ }
+
+ fp, err := os.OpenFile(filepath.Join(w.dir, "access.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ if err != nil {
+ remotelogs.Error("DNS_ACCESS_LOG_FILE", "open access.log failed: "+err.Error())
+ return err
+ }
+
+ w.file = fp
+ w.inited = true
+ return nil
+}
+
+// WriteBatch 批量写入 DNS 访问日志.
+func (w *DNSFileWriter) WriteBatch(logs []*pb.NSAccessLog, clusterId int64) {
+ if len(logs) == 0 || w.dir == "" {
+ return
+ }
+ if err := w.init(); err != nil {
+ return
+ }
+
+ w.mu.Lock()
+ fp := w.file
+ w.mu.Unlock()
+ if fp == nil {
+ return
+ }
+
+ for _, log := range logs {
+ ingestLog := FromNSAccessLog(log, clusterId)
+ if ingestLog == nil {
+ continue
+ }
+ line, err := json.Marshal(ingestLog)
+ if err != nil {
+ continue
+ }
+ _, _ = fp.Write(append(line, '\n'))
+ }
+}
+
+// Reopen 关闭并重新打开日志文件(配合 logrotate).
+func (w *DNSFileWriter) Reopen() error {
+ w.mu.Lock()
+ if w.file != nil {
+ _ = w.file.Close()
+ w.file = nil
+ }
+ w.inited = false
+ w.mu.Unlock()
+ return w.init()
+}
+
+// Close 关闭日志文件.
+func (w *DNSFileWriter) Close() error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ if w.file == nil {
+ return nil
+ }
+
+ err := w.file.Close()
+ w.file = nil
+ w.inited = false
+ if err != nil {
+ remotelogs.Error("DNS_ACCESS_LOG_FILE", fmt.Sprintf("close access.log failed: %v", err))
+ return err
+ }
+ return nil
+}
diff --git a/EdgeDNS/internal/accesslogs/dns_ingest_log.go b/EdgeDNS/internal/accesslogs/dns_ingest_log.go
new file mode 100644
index 0000000..55cdf6f
--- /dev/null
+++ b/EdgeDNS/internal/accesslogs/dns_ingest_log.go
@@ -0,0 +1,57 @@
+package accesslogs
+
+import (
+ "encoding/json"
+
+ "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
+)
+
+// DNSIngestLog DNS 访问日志单行结构(JSONEachRow).
+type DNSIngestLog struct {
+ Timestamp int64 `json:"timestamp"`
+ RequestId string `json:"request_id"`
+ NodeId int64 `json:"node_id"`
+ ClusterId int64 `json:"cluster_id"`
+ DomainId int64 `json:"domain_id"`
+ RecordId int64 `json:"record_id"`
+ RemoteAddr string `json:"remote_addr"`
+ QuestionName string `json:"question_name"`
+ QuestionType string `json:"question_type"`
+ RecordName string `json:"record_name"`
+ RecordType string `json:"record_type"`
+ RecordValue string `json:"record_value"`
+ Networking string `json:"networking"`
+ IsRecursive bool `json:"is_recursive"`
+ Error string `json:"error"`
+ NSRouteCodes []string `json:"ns_route_codes,omitempty"`
+ ContentJSON string `json:"content_json,omitempty"`
+}
+
+// FromNSAccessLog 将 pb.NSAccessLog 转为 DNSIngestLog.
+func FromNSAccessLog(log *pb.NSAccessLog, clusterId int64) *DNSIngestLog {
+ if log == nil {
+ return nil
+ }
+
+ contentBytes, _ := json.Marshal(log)
+
+ return &DNSIngestLog{
+ Timestamp: log.GetTimestamp(),
+ RequestId: log.GetRequestId(),
+ NodeId: log.GetNsNodeId(),
+ ClusterId: clusterId,
+ DomainId: log.GetNsDomainId(),
+ RecordId: log.GetNsRecordId(),
+ RemoteAddr: log.GetRemoteAddr(),
+ QuestionName: log.GetQuestionName(),
+ QuestionType: log.GetQuestionType(),
+ RecordName: log.GetRecordName(),
+ RecordType: log.GetRecordType(),
+ RecordValue: log.GetRecordValue(),
+ Networking: log.GetNetworking(),
+ IsRecursive: log.GetIsRecursive(),
+ Error: log.GetError(),
+ NSRouteCodes: log.GetNsRouteCodes(),
+ ContentJSON: string(contentBytes),
+ }
+}
diff --git a/EdgeDNS/internal/nodes/manager_node_config.go b/EdgeDNS/internal/nodes/manager_node_config.go
index fa990eb..5066b07 100644
--- a/EdgeDNS/internal/nodes/manager_node_config.go
+++ b/EdgeDNS/internal/nodes/manager_node_config.go
@@ -10,6 +10,7 @@ import (
"github.com/TeaOSLab/EdgeCommon/pkg/dnsconfigs"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
+ "github.com/TeaOSLab/EdgeDNS/internal/accesslogs"
"github.com/TeaOSLab/EdgeDNS/internal/configs"
teaconst "github.com/TeaOSLab/EdgeDNS/internal/const"
"github.com/TeaOSLab/EdgeDNS/internal/events"
@@ -108,6 +109,15 @@ func (this *NodeConfigManager) NotifyChange() {
func (this *NodeConfigManager) reload(config *dnsconfigs.NSNodeConfig) {
teaconst.IsPlus = config.IsPlus
+ accesslogs.SharedDNSFileWriter().SetDirByPolicyPath(config.AccessLogFilePath)
+
+ needWriteFile := config.AccessLogWriteTargets == nil || config.AccessLogWriteTargets.File || config.AccessLogWriteTargets.ClickHouse
+ if needWriteFile {
+ _ = accesslogs.SharedDNSFileWriter().EnsureInit()
+ } else {
+ _ = accesslogs.SharedDNSFileWriter().Close()
+ }
+
// timezone
var timeZone = config.TimeZone
if len(timeZone) == 0 {
diff --git a/EdgeDNS/internal/nodes/ns_access_log_queue.go b/EdgeDNS/internal/nodes/ns_access_log_queue.go
index f350d16..a541151 100644
--- a/EdgeDNS/internal/nodes/ns_access_log_queue.go
+++ b/EdgeDNS/internal/nodes/ns_access_log_queue.go
@@ -2,6 +2,7 @@ package nodes
import (
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
+ "github.com/TeaOSLab/EdgeDNS/internal/accesslogs"
"github.com/TeaOSLab/EdgeDNS/internal/remotelogs"
"github.com/TeaOSLab/EdgeDNS/internal/rpc"
"strconv"
@@ -89,6 +90,26 @@ Loop:
return nil
}
+ var clusterId int64
+ var needWriteFile = true
+ var needReportAPI = true
+ if sharedNodeConfig != nil {
+ clusterId = sharedNodeConfig.ClusterId
+ if sharedNodeConfig.AccessLogWriteTargets != nil {
+ targets := sharedNodeConfig.AccessLogWriteTargets
+ needWriteFile = targets.File || targets.ClickHouse
+ needReportAPI = targets.MySQL
+ }
+ }
+
+ if needWriteFile {
+ accesslogs.SharedDNSFileWriter().WriteBatch(accessLogs, clusterId)
+ }
+
+ if !needReportAPI {
+ return nil
+ }
+
// 发送到API
client, err := rpc.SharedRPC()
if err != nil {
diff --git a/EdgeNode/build/build-all-plus.sh b/EdgeNode/build/build-all-plus.sh
index 2f16a4a..d91ba94 100644
--- a/EdgeNode/build/build-all-plus.sh
+++ b/EdgeNode/build/build-all-plus.sh
@@ -2,8 +2,8 @@
./build.sh linux amd64 plus
#./build.sh linux 386 plus
-./build.sh linux arm64 plus
+#./build.sh linux arm64 plus
#./build.sh linux mips64 plus
#./build.sh linux mips64le plus
#./build.sh darwin amd64 plus
-#./build.sh darwin arm64 plus
\ No newline at end of file
+#./build.sh darwin arm64 plus
diff --git a/EdgeNode/build/build-all.sh b/EdgeNode/build/build-all.sh
index bcc2e23..9ee68d4 100644
--- a/EdgeNode/build/build-all.sh
+++ b/EdgeNode/build/build-all.sh
@@ -2,8 +2,8 @@
./build.sh linux amd64
#./build.sh linux 386
-./build.sh linux arm64
+#./build.sh linux arm64
#./build.sh linux mips64
#./build.sh linux mips64le
#./build.sh darwin amd64
-#./build.sh darwin arm64
\ No newline at end of file
+#./build.sh darwin arm64
diff --git a/EdgeNode/build/data/disk.speed.json b/EdgeNode/build/data/disk.speed.json
index 8d3ae39..8ed61c3 100644
--- a/EdgeNode/build/data/disk.speed.json
+++ b/EdgeNode/build/data/disk.speed.json
@@ -1 +1 @@
-{"speed":2,"speedMB":670,"countTests":2}
\ No newline at end of file
+{"speed":1,"speedMB":1400,"countTests":3}
\ No newline at end of file
diff --git a/EdgeNode/internal/accesslogs/file_writer.go b/EdgeNode/internal/accesslogs/file_writer.go
index da177b5..264df8f 100644
--- a/EdgeNode/internal/accesslogs/file_writer.go
+++ b/EdgeNode/internal/accesslogs/file_writer.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"sync"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
@@ -39,21 +40,73 @@ type FileWriter struct {
// NewFileWriter 创建本地日志文件写入器
func NewFileWriter() *FileWriter {
- dir := os.Getenv(envLogDir)
- if dir == "" {
- dir = defaultLogDir
- }
+ dir := resolveDefaultLogDir()
return &FileWriter{
dir: dir,
files: make(map[string]*os.File),
}
}
+func resolveDefaultLogDir() string {
+ dir := strings.TrimSpace(os.Getenv(envLogDir))
+ if dir == "" {
+ return defaultLogDir
+ }
+ return dir
+}
+
+func resolveDirFromPolicyPath(policyPath string) string {
+ policyPath = strings.TrimSpace(policyPath)
+ if policyPath == "" {
+ return ""
+ }
+
+ if strings.HasSuffix(policyPath, "/") || strings.HasSuffix(policyPath, "\\") {
+ return filepath.Clean(policyPath)
+ }
+
+ baseName := filepath.Base(policyPath)
+ if strings.Contains(baseName, ".") || strings.Contains(baseName, "${") {
+ return filepath.Clean(filepath.Dir(policyPath))
+ }
+
+ return filepath.Clean(policyPath)
+}
+
// Dir 返回当前配置的日志目录
func (w *FileWriter) Dir() string {
return w.dir
}
+// SetDirByPolicyPath 使用公用日志策略 path 更新目录,空值时回退到 EDGE_LOG_DIR/default。
+func (w *FileWriter) SetDirByPolicyPath(policyPath string) {
+ dir := resolveDirFromPolicyPath(policyPath)
+ w.SetDir(dir)
+}
+
+// SetDir 更新日志目录并重置文件句柄。
+func (w *FileWriter) SetDir(dir string) {
+ if strings.TrimSpace(dir) == "" {
+ dir = resolveDefaultLogDir()
+ }
+
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ if dir == w.dir {
+ return
+ }
+
+ for name, f := range w.files {
+ if f != nil {
+ _ = f.Close()
+ }
+ w.files[name] = nil
+ }
+ w.inited = false
+ w.dir = dir
+}
+
// IsEnabled 是否启用落盘(目录非空即视为启用)
func (w *FileWriter) IsEnabled() bool {
return w.dir != ""
diff --git a/EdgeNode/internal/nodes/node.go b/EdgeNode/internal/nodes/node.go
index 0d0ed36..77180c8 100644
--- a/EdgeNode/internal/nodes/node.go
+++ b/EdgeNode/internal/nodes/node.go
@@ -887,6 +887,12 @@ func (this *Node) onReload(config *nodeconfigs.NodeConfig, reloadAll bool) {
nodeconfigs.ResetNodeConfig(config)
sharedNodeConfig = config
+ var accessLogFilePath string
+ if config != nil && config.GlobalServerConfig != nil {
+ accessLogFilePath = config.GlobalServerConfig.HTTPAccessLog.FilePath
+ }
+ accesslogs.SharedFileWriter().SetDirByPolicyPath(accessLogFilePath)
+
// 并发读写数
fsutils.ReaderLimiter.SetThreads(config.MaxConcurrentReads)
fsutils.WriterLimiter.SetThreads(config.MaxConcurrentWrites)
diff --git a/EdgeReporter/build/build-all.sh b/EdgeReporter/build/build-all.sh
index f1a5ceb..ed976be 100644
--- a/EdgeReporter/build/build-all.sh
+++ b/EdgeReporter/build/build-all.sh
@@ -2,10 +2,10 @@
./build.sh linux amd64
./build.sh linux 386
-./build.sh linux arm64
+#./build.sh linux arm64
./build.sh linux mips64
./build.sh linux mips64le
./build.sh darwin amd64
-./build.sh darwin arm64
+#./build.sh darwin arm64
./build.sh windows amd64
-./build.sh windows 386
\ No newline at end of file
+./build.sh windows 386
diff --git a/EdgeUser/build/build-all.sh b/EdgeUser/build/build-all.sh
index bcc2e23..9ee68d4 100644
--- a/EdgeUser/build/build-all.sh
+++ b/EdgeUser/build/build-all.sh
@@ -2,8 +2,8 @@
./build.sh linux amd64
#./build.sh linux 386
-./build.sh linux arm64
+#./build.sh linux arm64
#./build.sh linux mips64
#./build.sh linux mips64le
#./build.sh darwin amd64
-#./build.sh darwin arm64
\ No newline at end of file
+#./build.sh darwin arm64
diff --git a/GoEdge HTTPDNS 需求文档 v2.0.docx b/GoEdge HTTPDNS 需求文档 v2.0.docx
new file mode 100644
index 0000000..2b583de
Binary files /dev/null and b/GoEdge HTTPDNS 需求文档 v2.0.docx differ
diff --git a/HTTPDNS_技术实施方案.md b/HTTPDNS_技术实施方案.md
new file mode 100644
index 0000000..dbc44b3
--- /dev/null
+++ b/HTTPDNS_技术实施方案.md
@@ -0,0 +1,1290 @@
+# GoEdge HTTPDNS 技术实施方案
+
+> 版本: 1.0 | 作者: AI Assistant | 日期: 2026-02-09
+
+---
+
+## 一、项目概述
+
+### 1.1 目标
+在 GoEdge 平台实现完整的 HTTPDNS 服务,包括:
+- 基于 HTTPS 的 DNS 解析接口
+- 动态指纹校验(WAF)
+- App 管理后台
+- 移动端 SDK 示例
+
+### 1.2 设计决策
+| 决策项 | 选择 | 理由 |
+|--------|------|------|
+| WAF 指纹校验 | 必须 | 防止非法请求绕过解析直接攻击源站 |
+| App 管理界面 | 必须 | 标准产品功能,支持多租户 |
+| SDK 示例 | 提供 | 降低客户接入成本 |
+| 部署位置 | 复用 Edge-DNS | 减少运维复杂度 |
+| 接口路径 | 新增 `/httpdns/resolve` | 保持向后兼容 |
+
+---
+
+## 二、系统架构
+
+### 2.1 整体架构图
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ 移动端 App │
+│ ┌───────────────────────────────────────────────────────────────────┐ │
+│ │ HTTPDNS SDK │ │
+│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
+│ │ │ Resolver │ │CacheManager │ │NetworkMonitor│ │ │
+│ │ │ 解析引擎 │ │ 缓存管理 │ │ 网络感知 │ │ │
+│ │ │ -多节点容错 │ │ -内存LRU │ │ -切换监听 │ │ │
+│ │ │ -超时降级 │ │ -持久化 │ │ -自动清缓存 │ │ │
+│ │ └─────────────┘ │ -软过期 │ │ -IPv6检测 │ │ │
+│ │ └─────────────┘ └─────────────┘ │ │
+│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
+│ │ │ Signer │ │ Reporter │ │ Prefetch │ │ │
+│ │ │ 签名模块 │ │ 监控上报 │ │ 预解析 │ │ │
+│ │ │ -HMAC-SHA256│ │ -成功率 │ │ -冷启动优化 │ │ │
+│ │ │ -防重放 │ │ -耗时统计 │ │ -批量解析 │ │ │
+│ │ └─────────────┘ │ -缓存命中率│ └─────────────┘ │ │
+│ │ └─────────────┘ │ │
+│ └───────────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────┘
+ │
+ │ HTTPS
+ ▼
+┌─────────────────────────────────────────────────────────────────────────┐
+│ GoEdge 平台 │
+│ │
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ Edge-DNS │ │ Edge-Node │ │ Edge-Admin │ │
+│ │ │ │ │ │ │ │
+│ │ /httpdns/ │ │ WAF 校验 │ │ App 管理 │ │
+│ │ resolve │ │ 指纹验证 │ │ AppID/Secret│ │
+│ │ │ │ │ │ │ │
+│ │ 智能调度 │ │ 流量转发 │ │ SDK统计接收 │ │
+│ └──────────────┘ └──────────────┘ └──────────────┘ │
+│ │ │ │ │
+│ └───────────────────┴───────────────────┘ │
+│ │ │
+│ ┌──────────────┐ │
+│ │ Edge-API │ │
+│ │ 数据服务 │ │
+│ └──────────────┘ │
+│ │ │
+│ ┌──────────────┐ │
+│ │ MySQL │ │
+│ └──────────────┘ │
+└─────────────────────────────────────────────────────────────────────────┘
+ │
+ │ HTTPS (IP 直连)
+ ▼
+┌─────────────────────────────────────────────────────────────────────────┐
+│ 源站服务器 │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+### 2.2 请求流程(带缓存与降级)
+
+```mermaid
+sequenceDiagram
+ participant App as 移动 App
+ participant Cache as SDK缓存
+ participant Resolver as SDK解析器
+ participant DNS as Edge-DNS
+ participant SysDNS as 系统DNS
+ participant Node as Edge-Node
+ participant API as Edge-API
+
+ Note over App,API: 阶段一:DNS 解析(含缓存与降级)
+
+ App->>Resolver: resolve("api.example.com")
+
+ Resolver->>Cache: 查询缓存
+ alt 缓存有效
+ Cache-->>Resolver: 返回 IP (命中)
+ Resolver-->>App: ["1.2.3.4"]
+ else 缓存软过期
+ Cache-->>Resolver: 返回 IP + 标记需刷新
+ Resolver-->>App: ["1.2.3.4"] (先返回)
+ Resolver->>DNS: 后台异步刷新
+ DNS-->>Resolver: 新 IP
+ Resolver->>Cache: 更新缓存
+ else 缓存完全过期或不存在
+ Resolver->>DNS: GET /httpdns/resolve
+ alt HTTPDNS 正常
+ DNS-->>Resolver: {"ips": ["1.2.3.4"], "ttl": 600}
+ Resolver->>Cache: 写入缓存
+ Resolver-->>App: ["1.2.3.4"]
+ else HTTPDNS 超时/失败
+ Resolver->>SysDNS: 降级到系统 DNS
+ SysDNS-->>Resolver: 1.2.3.4
+ Resolver-->>App: ["1.2.3.4"] (降级)
+ end
+ end
+
+ Note over App,API: 阶段二:业务请求(带签名)
+ App->>Node: HTTPS://1.2.3.4/v1/user + 签名Header
+ Node->>API: 查询 AppSecret
+ API-->>Node: AppSecret
+ Node->>Node: HMAC-SHA256 验证
+ alt 验证失败
+ Node-->>App: 403 Forbidden
+ else 验证成功
+ Node-->>App: 200 OK + 响应数据
+ end
+
+ Note over App,API: 阶段三:监控上报(异步)
+ Resolver-->>API: 定时上报统计数据
+```
+
+### 2.3 网络切换处理流程
+
+```mermaid
+flowchart LR
+ A[WiFi] -->|切换| B[4G/5G]
+ B --> C{NetworkMonitor检测}
+ C --> D[清空所有缓存]
+ D --> E[下次请求重新解析]
+ E --> F[获取新网络最优IP]
+```
+
+---
+
+## 三、模块详细设计
+
+### 3.1 Edge-DNS: HTTPDNS 解析接口
+
+#### 3.1.1 接口定义
+
+| 项目 | 说明 |
+|------|------|
+| **Endpoint** | `GET /httpdns/resolve` |
+| **协议** | HTTPS (443) |
+| **认证** | 无(解析接口公开,业务请求才需要签名) |
+
+#### 3.1.2 请求参数
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `host` | string | 是 | 待解析的域名 |
+| `type` | string | 否 | 记录类型,默认 `A,AAAA`(同时返回) |
+| `ip` | string | 否 | 客户端 IP(用于调试/代理场景) |
+
+#### 3.1.3 响应格式
+
+```json
+{
+ "status": "ok",
+ "dns_server_time": 1700000000,
+ "client_ip": "114.114.114.114",
+ "data": [
+ {
+ "host": "api.example.com",
+ "type": "A",
+ "ips": ["1.1.1.1", "1.1.1.2"],
+ "ips_v6": ["240e:xxx::1"],
+ "ttl": 600
+ }
+ ]
+}
+```
+
+#### 3.1.4 代码实现
+
+**文件**: `EdgeDNS/internal/nodes/httpdns.go` (新建)
+
+```go
+package nodes
+
+import (
+ "encoding/json"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// HTTPDNSResponse HTTPDNS 响应结构
+type HTTPDNSResponse struct {
+ Status string `json:"status"`
+ DNSServerTime int64 `json:"dns_server_time"`
+ ClientIP string `json:"client_ip"`
+ Data []HTTPDNSRecord `json:"data"`
+ Error string `json:"error,omitempty"`
+}
+
+// HTTPDNSRecord 单条解析记录
+type HTTPDNSRecord struct {
+ Host string `json:"host"`
+ Type string `json:"type"`
+ IPs []string `json:"ips"`
+ IPsV6 []string `json:"ips_v6"`
+ TTL int `json:"ttl"`
+}
+
+// handleHTTPDNSResolve 处理 HTTPDNS 解析请求
+func (this *Server) handleHTTPDNSResolve(writer http.ResponseWriter, req *http.Request) {
+ writer.Header().Set("Content-Type", "application/json")
+ writer.Header().Set("Access-Control-Allow-Origin", "*")
+
+ // 1. 解析参数
+ query := req.URL.Query()
+ host := strings.TrimSpace(query.Get("host"))
+ if host == "" {
+ this.writeHTTPDNSError(writer, "missing 'host' parameter")
+ return
+ }
+
+ // 2. 获取客户端 IP
+ clientIP := query.Get("ip")
+ if clientIP == "" {
+ clientIP = this.extractClientIP(req)
+ }
+
+ // 3. 查询 A 记录
+ ipsV4 := this.resolveRecords(host, "A", clientIP)
+
+ // 4. 查询 AAAA 记录
+ ipsV6 := this.resolveRecords(host, "AAAA", clientIP)
+
+ // 5. 获取 TTL
+ ttl := this.getRecordTTL(host, clientIP)
+ if ttl == 0 {
+ ttl = 600
+ }
+
+ // 6. 构造响应
+ resp := HTTPDNSResponse{
+ Status: "ok",
+ DNSServerTime: time.Now().Unix(),
+ ClientIP: clientIP,
+ Data: []HTTPDNSRecord{{
+ Host: host,
+ Type: "A",
+ IPs: ipsV4,
+ IPsV6: ipsV6,
+ TTL: ttl,
+ }},
+ }
+
+ json.NewEncoder(writer).Encode(resp)
+}
+
+// extractClientIP 从请求中提取客户端真实 IP
+func (this *Server) extractClientIP(req *http.Request) string {
+ xff := req.Header.Get("X-Forwarded-For")
+ if xff != "" {
+ parts := strings.Split(xff, ",")
+ return strings.TrimSpace(parts[0])
+ }
+ xri := req.Header.Get("X-Real-IP")
+ if xri != "" {
+ return xri
+ }
+ host, _, _ := net.SplitHostPort(req.RemoteAddr)
+ return host
+}
+
+// resolveRecords 解析指定类型的记录
+func (this *Server) resolveRecords(host, recordType, clientIP string) []string {
+ var result []string
+ if !strings.HasSuffix(host, ".") {
+ host += "."
+ }
+ domain, recordName := sharedDomainManager.SplitDomain(host)
+ if domain == nil {
+ return result
+ }
+ routeCodes := sharedRouteManager.FindRouteCodes(clientIP, domain.UserId)
+ records, _ := sharedRecordManager.FindRecords(domain.Id, routeCodes, recordName, recordType, false)
+ for _, record := range records {
+ if record.Value != "" {
+ result = append(result, record.Value)
+ }
+ }
+ return result
+}
+
+// getRecordTTL 获取记录 TTL
+func (this *Server) getRecordTTL(host, clientIP string) int {
+ if !strings.HasSuffix(host, ".") {
+ host += "."
+ }
+ domain, recordName := sharedDomainManager.SplitDomain(host)
+ if domain == nil {
+ return 0
+ }
+ routeCodes := sharedRouteManager.FindRouteCodes(clientIP, domain.UserId)
+ records, _ := sharedRecordManager.FindRecords(domain.Id, routeCodes, recordName, "A", false)
+ if len(records) > 0 {
+ return int(records[0].Ttl)
+ }
+ return 0
+}
+
+// writeHTTPDNSError 写入错误响应
+func (this *Server) writeHTTPDNSError(writer http.ResponseWriter, errMsg string) {
+ writer.WriteHeader(http.StatusBadRequest)
+ resp := HTTPDNSResponse{
+ Status: "error",
+ DNSServerTime: time.Now().Unix(),
+ Error: errMsg,
+ }
+ json.NewEncoder(writer).Encode(resp)
+}
+```
+
+**修改**: `EdgeDNS/internal/nodes/server.go` 第735行附近
+
+```go
+func (this *Server) handleHTTP(writer http.ResponseWriter, req *http.Request) {
+ if req.URL.Path == "/dns-query" {
+ this.handleHTTPDNSMessage(writer, req)
+ return
+ }
+ // 新增 HTTPDNS JSON API
+ if req.URL.Path == "/httpdns/resolve" {
+ this.handleHTTPDNSResolve(writer, req)
+ return
+ }
+ if req.URL.Path == "/resolve" {
+ this.handleHTTPJSONAPI(writer, req)
+ return
+ }
+ writer.WriteHeader(http.StatusNotFound)
+}
+```
+
+---
+
+### 3.2 Edge-Node: WAF 指纹校验
+
+#### 3.2.1 校验逻辑
+
+| 步骤 | 说明 |
+|------|------|
+| 1 | 提取 `X-GE-AppID`, `X-GE-Timestamp`, `X-GE-Token` |
+| 2 | 根据 AppID 查询 AppSecret |
+| 3 | 校验时间戳(±300 秒内有效) |
+| 4 | 计算 HMAC-SHA256 并比对 |
+
+#### 3.2.2 代码实现
+
+**文件**: `EdgeNode/internal/waf/checkpoints/checkpoint_httpdns_fingerprint.go` (新建)
+
+```go
+package checkpoints
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "math"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
+ "github.com/TeaOSLab/EdgeNode/internal/rpc"
+)
+
+type CheckpointHTTPDNSFingerprint struct {
+ Checkpoint
+}
+
+func (this *CheckpointHTTPDNSFingerprint) RequestValue(
+ req CheckpointRequest, param string, options map[string]string, ruleId int64,
+) (value any, hasRequestBody bool, sysErr error, userErr error) {
+ httpReq, ok := req.WAFRaw().(*http.Request)
+ if !ok {
+ return "INVALID_REQUEST", false, nil, nil
+ }
+
+ appID := httpReq.Header.Get("X-GE-AppID")
+ token := httpReq.Header.Get("X-GE-Token")
+ tsStr := httpReq.Header.Get("X-GE-Timestamp")
+
+ if appID == "" && token == "" && tsStr == "" {
+ return "", false, nil, nil
+ }
+ if appID == "" {
+ return "MISSING_APPID", false, nil, nil
+ }
+ if token == "" {
+ return "MISSING_TOKEN", false, nil, nil
+ }
+ if tsStr == "" {
+ return "MISSING_TIMESTAMP", false, nil, nil
+ }
+
+ appSecret, err := this.getAppSecret(appID)
+ if err != nil || appSecret == "" {
+ return "INVALID_APPID", false, nil, nil
+ }
+
+ ts, _ := strconv.ParseInt(tsStr, 10, 64)
+ if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
+ return "TIMESTAMP_EXPIRED", false, nil, nil
+ }
+
+ mac := hmac.New(sha256.New, []byte(appSecret))
+ mac.Write([]byte(appID + tsStr + httpReq.URL.Path))
+ expected := hex.EncodeToString(mac.Sum(nil))
+
+ if token != expected {
+ return "SIGNATURE_MISMATCH", false, nil, nil
+ }
+ return "OK", false, nil, nil
+}
+
+func (this *CheckpointHTTPDNSFingerprint) ResponseValue(
+ req CheckpointRequest, param string, options map[string]string, ruleId int64,
+) (value any, hasRequestBody bool, sysErr error, userErr error) {
+ return "", false, nil, nil
+}
+
+func (this *CheckpointHTTPDNSFingerprint) getAppSecret(appID string) (string, error) {
+ client, err := rpc.SharedRPC()
+ if err != nil {
+ return "", err
+ }
+ resp, err := client.HTTPDNSAppRPC.FindHTTPDNSAppSecret(
+ client.Context(), &pb.FindHTTPDNSAppSecretRequest{AppId: appID},
+ )
+ if err != nil {
+ return "", err
+ }
+ return resp.AppSecret, nil
+}
+```
+
+---
+
+### 3.3 Edge-API: App 管理服务
+
+#### 3.3.1 数据库表
+
+```sql
+CREATE TABLE IF NOT EXISTS edgeHTTPDNSApps (
+ id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
+ appId VARCHAR(64) NOT NULL UNIQUE COMMENT 'App标识',
+ appSecret VARCHAR(128) NOT NULL COMMENT 'App密钥',
+ name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '应用名称',
+ description TEXT COMMENT '描述',
+ userId BIGINT UNSIGNED DEFAULT 0 COMMENT '关联用户ID',
+ isOn TINYINT(1) DEFAULT 1 COMMENT '是否启用',
+ createdAt INT UNSIGNED DEFAULT 0,
+ state TINYINT(1) DEFAULT 1,
+ UNIQUE KEY uk_appId (appId),
+ KEY idx_userId (userId)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+#### 3.3.2 gRPC Proto
+
+**文件**: `EdgeCommon/pkg/rpc/protos/service_httpdns_app.proto` (新建)
+
+```protobuf
+syntax = "proto3";
+option go_package = "./pb";
+package pb;
+
+service HTTPDNSAppService {
+ rpc createHTTPDNSApp(CreateHTTPDNSAppRequest) returns (CreateHTTPDNSAppResponse);
+ rpc findHTTPDNSAppSecret(FindHTTPDNSAppSecretRequest) returns (FindHTTPDNSAppSecretResponse);
+ rpc listHTTPDNSApps(ListHTTPDNSAppsRequest) returns (ListHTTPDNSAppsResponse);
+ rpc deleteHTTPDNSApp(DeleteHTTPDNSAppRequest) returns (RPCSuccess);
+}
+
+message CreateHTTPDNSAppRequest {
+ string name = 1;
+ string description = 2;
+ int64 userId = 3;
+}
+message CreateHTTPDNSAppResponse {
+ int64 httpdnsAppId = 1;
+ string appId = 2;
+ string appSecret = 3;
+}
+message FindHTTPDNSAppSecretRequest {
+ string appId = 1;
+}
+message FindHTTPDNSAppSecretResponse {
+ string appSecret = 1;
+}
+message ListHTTPDNSAppsRequest {
+ int64 userId = 1;
+ int64 offset = 2;
+ int64 size = 3;
+}
+message ListHTTPDNSAppsResponse {
+ repeated HTTPDNSApp httpdnsApps = 1;
+}
+message DeleteHTTPDNSAppRequest {
+ int64 httpdnsAppId = 1;
+}
+message HTTPDNSApp {
+ int64 id = 1;
+ string appId = 2;
+ string appSecret = 3;
+ string name = 4;
+ int64 userId = 5;
+ bool isOn = 6;
+ int64 createdAt = 7;
+}
+```
+
+---
+
+### 3.4 SDK 完整设计
+
+#### 3.4.1 SDK 架构概览
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ HTTPDNS SDK │
+├─────────────────────────────────────────────────────────────┤
+│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
+│ │ 解析引擎 │ │ 缓存管理 │ │ 网络感知 │ │
+│ │ Resolver │ │ CacheManager │ │ NetworkMonitor│ │
+│ └───────────────┘ └───────────────┘ └───────────────┘ │
+│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
+│ │ 容错降级 │ │ 签名模块 │ │ 监控上报 │ │
+│ │ Failover │ │ Signer │ │ Reporter │ │
+│ └───────────────┘ └───────────────┘ └───────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+#### 3.4.2 缓存管理模块 (CacheManager)
+
+**设计原则**:
+- 两级缓存:内存 + 持久化
+- 软过期策略:过期后先返回旧数据,后台异步更新
+- LRU 淘汰:防止内存溢出
+
+**Android 实现**:
+
+```kotlin
+class HTTPDNSCacheManager(private val context: Context) {
+ // 内存缓存 (LRU)
+ private val memoryCache = object : LinkedHashMap(100, 0.75f, true) {
+ override fun removeEldestEntry(eldest: Map.Entry): Boolean {
+ return size > MAX_CACHE_SIZE
+ }
+ }
+
+ // 持久化缓存 (MMKV)
+ private val mmkv = MMKV.mmkvWithID("httpdns_cache", MMKV.MULTI_PROCESS_MODE)
+
+ companion object {
+ const val MAX_CACHE_SIZE = 100
+ const val SOFT_EXPIRE_SECONDS = 60 // 软过期:TTL 到期后仍可用 60s
+ }
+
+ data class CacheEntry(
+ val ips: List,
+ val ipsV6: List,
+ val expireAt: Long, // 硬过期时间
+ val softExpireAt: Long, // 软过期时间 = expireAt + SOFT_EXPIRE_SECONDS
+ val createAt: Long = System.currentTimeMillis()
+ ) {
+ fun isExpired(): Boolean = System.currentTimeMillis() > expireAt
+ fun isSoftExpired(): Boolean = System.currentTimeMillis() > softExpireAt
+ fun toJson(): String = Gson().toJson(this)
+ }
+
+ /**
+ * 获取缓存(支持软过期)
+ * @return Pair<缓存结果, 是否需要后台刷新>
+ */
+ @Synchronized
+ fun get(host: String): Pair {
+ // 1. 先查内存
+ memoryCache[host]?.let { entry ->
+ return when {
+ !entry.isExpired() -> Pair(entry, false) // 未过期
+ !entry.isSoftExpired() -> Pair(entry, true) // 软过期,需刷新
+ else -> Pair(null, true) // 完全过期
+ }
+ }
+
+ // 2. 查持久化
+ val json = mmkv.decodeString(host) ?: return Pair(null, true)
+ val entry = Gson().fromJson(json, CacheEntry::class.java)
+
+ // 回填内存
+ memoryCache[host] = entry
+
+ return when {
+ !entry.isExpired() -> Pair(entry, false)
+ !entry.isSoftExpired() -> Pair(entry, true)
+ else -> Pair(null, true)
+ }
+ }
+
+ /**
+ * 写入缓存
+ */
+ @Synchronized
+ fun put(host: String, ips: List, ipsV6: List, ttl: Int) {
+ val now = System.currentTimeMillis()
+ val entry = CacheEntry(
+ ips = ips,
+ ipsV6 = ipsV6,
+ expireAt = now + ttl * 1000L,
+ softExpireAt = now + (ttl + SOFT_EXPIRE_SECONDS) * 1000L
+ )
+ memoryCache[host] = entry
+ mmkv.encode(host, entry.toJson())
+ }
+
+ /**
+ * 清空所有缓存(网络切换时调用)
+ */
+ @Synchronized
+ fun clear() {
+ memoryCache.clear()
+ mmkv.clearAll()
+ }
+}
+```
+
+**iOS 实现**:
+
+```swift
+class HTTPDNSCacheManager {
+ static let shared = HTTPDNSCacheManager()
+
+ private var memoryCache: [String: CacheEntry] = [:]
+ private let defaults = UserDefaults(suiteName: "com.goedge.httpdns")!
+ private let queue = DispatchQueue(label: "httpdns.cache", attributes: .concurrent)
+
+ private let maxCacheSize = 100
+ private let softExpireSeconds: TimeInterval = 60
+
+ struct CacheEntry: Codable {
+ let ips: [String]
+ let ipsV6: [String]
+ let expireAt: Date
+ let softExpireAt: Date
+ let createAt: Date
+
+ func isExpired() -> Bool { Date() > expireAt }
+ func isSoftExpired() -> Bool { Date() > softExpireAt }
+ }
+
+ func get(host: String) -> (entry: CacheEntry?, needRefresh: Bool) {
+ return queue.sync {
+ // 查内存
+ if let entry = memoryCache[host] {
+ if !entry.isExpired() { return (entry, false) }
+ if !entry.isSoftExpired() { return (entry, true) }
+ }
+
+ // 查持久化
+ guard let data = defaults.data(forKey: host),
+ let entry = try? JSONDecoder().decode(CacheEntry.self, from: data) else {
+ return (nil, true)
+ }
+
+ // 回填内存
+ memoryCache[host] = entry
+
+ if !entry.isExpired() { return (entry, false) }
+ if !entry.isSoftExpired() { return (entry, true) }
+ return (nil, true)
+ }
+ }
+
+ func put(host: String, ips: [String], ipsV6: [String], ttl: Int) {
+ queue.async(flags: .barrier) {
+ let entry = CacheEntry(
+ ips: ips,
+ ipsV6: ipsV6,
+ expireAt: Date().addingTimeInterval(TimeInterval(ttl)),
+ softExpireAt: Date().addingTimeInterval(TimeInterval(ttl) + self.softExpireSeconds),
+ createAt: Date()
+ )
+ self.memoryCache[host] = entry
+
+ // LRU 淘汰
+ if self.memoryCache.count > self.maxCacheSize {
+ let oldest = self.memoryCache.min { $0.value.createAt < $1.value.createAt }
+ if let key = oldest?.key { self.memoryCache.removeValue(forKey: key) }
+ }
+
+ // 持久化
+ if let data = try? JSONEncoder().encode(entry) {
+ self.defaults.set(data, forKey: host)
+ }
+ }
+ }
+
+ func clear() {
+ queue.async(flags: .barrier) {
+ self.memoryCache.removeAll()
+ // 清理持久化
+ for key in self.defaults.dictionaryRepresentation().keys {
+ self.defaults.removeObject(forKey: key)
+ }
+ }
+ }
+}
+```
+
+#### 3.4.3 网络感知模块 (NetworkMonitor)
+
+**功能**:
+- 监听网络切换(WiFi ↔ 4G/5G)
+- 网络切换时清空缓存
+- 检测 IPv6 可用性
+
+**Android 实现**:
+
+```kotlin
+class NetworkMonitor(private val context: Context) {
+ private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ private var lastNetworkType: String? = null
+ var onNetworkChanged: (() -> Unit)? = null
+
+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ checkNetworkChange()
+ }
+
+ override fun onLost(network: Network) {
+ checkNetworkChange()
+ }
+
+ override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
+ checkNetworkChange()
+ }
+ }
+
+ fun start() {
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+ connectivityManager.registerNetworkCallback(request, networkCallback)
+ lastNetworkType = getCurrentNetworkType()
+ }
+
+ fun stop() {
+ connectivityManager.unregisterNetworkCallback(networkCallback)
+ }
+
+ private fun checkNetworkChange() {
+ val currentType = getCurrentNetworkType()
+ if (currentType != lastNetworkType) {
+ Log.d("HTTPDNS", "Network changed: $lastNetworkType -> $currentType")
+ lastNetworkType = currentType
+ onNetworkChanged?.invoke()
+ }
+ }
+
+ private fun getCurrentNetworkType(): String {
+ val network = connectivityManager.activeNetwork ?: return "NONE"
+ val caps = connectivityManager.getNetworkCapabilities(network) ?: return "UNKNOWN"
+ return when {
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WIFI"
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "CELLULAR"
+ else -> "OTHER"
+ }
+ }
+
+ /**
+ * 检测当前网络是否支持 IPv6
+ */
+ fun isIPv6Supported(): Boolean {
+ return try {
+ val addresses = NetworkInterface.getNetworkInterfaces().toList()
+ .flatMap { it.inetAddresses.toList() }
+ addresses.any { it is Inet6Address && !it.isLoopbackAddress && !it.isLinkLocalAddress }
+ } catch (e: Exception) {
+ false
+ }
+ }
+}
+```
+
+**iOS 实现**:
+
+```swift
+import Network
+
+class NetworkMonitor {
+ static let shared = NetworkMonitor()
+
+ private let monitor = NWPathMonitor()
+ private let queue = DispatchQueue(label: "httpdns.network")
+ private var lastInterfaceType: NWInterface.InterfaceType?
+
+ var onNetworkChanged: (() -> Void)?
+
+ func start() {
+ monitor.pathUpdateHandler = { [weak self] path in
+ let currentType = path.availableInterfaces.first?.type
+ if currentType != self?.lastInterfaceType {
+ print("HTTPDNS: Network changed \(self?.lastInterfaceType?.description ?? "nil") -> \(currentType?.description ?? "nil")")
+ self?.lastInterfaceType = currentType
+ DispatchQueue.main.async {
+ self?.onNetworkChanged?()
+ }
+ }
+ }
+ monitor.start(queue: queue)
+ }
+
+ func stop() {
+ monitor.cancel()
+ }
+
+ func isIPv6Supported() -> Bool {
+ let path = monitor.currentPath
+ return path.supportsIPv6
+ }
+}
+```
+
+#### 3.4.4 容错降级模块 (Failover)
+
+**策略**:
+1. 多节点轮询:主节点失败自动切备用
+2. 超时降级:HTTPDNS 超时自动降级到系统 DNS
+3. 黑名单机制:特定域名强制走系统 DNS
+
+**Android 实现**:
+
+```kotlin
+class HTTPDNSResolver(
+ private val serverUrls: List, // 多个服务节点
+ private val cacheManager: HTTPDNSCacheManager,
+ private val timeout: Long = 3000L
+) {
+ private val blacklist = setOf("localhost", "*.local", "*.internal")
+ private var currentServerIndex = 0
+
+ suspend fun resolve(host: String): ResolveResult {
+ // 1. 黑名单检查
+ if (isBlacklisted(host)) {
+ return ResolveResult(systemResolve(host), ResolveSource.SYSTEM_DNS)
+ }
+
+ // 2. 查缓存
+ val (cached, needRefresh) = cacheManager.get(host)
+ if (cached != null) {
+ if (needRefresh) {
+ // 后台异步刷新
+ CoroutineScope(Dispatchers.IO).launch { fetchFromServer(host) }
+ }
+ return ResolveResult(cached.ips, ResolveSource.CACHE)
+ }
+
+ // 3. 请求服务器(带重试)
+ return try {
+ withTimeout(timeout) {
+ val ips = fetchFromServerWithRetry(host)
+ ResolveResult(ips, ResolveSource.HTTPDNS)
+ }
+ } catch (e: TimeoutCancellationException) {
+ // 4. 降级到系统 DNS
+ Log.w("HTTPDNS", "Timeout, fallback to system DNS")
+ ResolveResult(systemResolve(host), ResolveSource.SYSTEM_DNS)
+ }
+ }
+
+ private suspend fun fetchFromServerWithRetry(host: String): List {
+ var lastError: Exception? = null
+ repeat(serverUrls.size) { attempt ->
+ try {
+ return fetchFromServer(host)
+ } catch (e: Exception) {
+ lastError = e
+ currentServerIndex = (currentServerIndex + 1) % serverUrls.size
+ Log.w("HTTPDNS", "Server ${serverUrls[currentServerIndex]} failed, trying next")
+ }
+ }
+ throw lastError ?: Exception("All servers failed")
+ }
+
+ private suspend fun fetchFromServer(host: String): List {
+ val url = "${serverUrls[currentServerIndex]}/httpdns/resolve?host=$host"
+ val response = httpClient.get(url)
+ val result = parseResponse(response.body)
+ cacheManager.put(host, result.ips, result.ipsV6, result.ttl)
+ return result.ips
+ }
+
+ private fun systemResolve(host: String): List {
+ return try {
+ InetAddress.getAllByName(host).map { it.hostAddress }
+ } catch (e: Exception) {
+ emptyList()
+ }
+ }
+
+ private fun isBlacklisted(host: String): Boolean {
+ return blacklist.any { pattern ->
+ if (pattern.startsWith("*")) {
+ host.endsWith(pattern.substring(1))
+ } else {
+ host == pattern
+ }
+ }
+ }
+
+ enum class ResolveSource { CACHE, HTTPDNS, SYSTEM_DNS }
+ data class ResolveResult(val ips: List, val source: ResolveSource)
+}
+```
+
+#### 3.4.5 监控上报模块 (Reporter)
+
+**上报指标**:
+- 解析成功率
+- 解析耗时
+- 缓存命中率
+- 降级次数
+
+**数据结构**:
+
+```kotlin
+data class HTTPDNSStats(
+ val host: String,
+ val resolveCount: Int,
+ val successCount: Int,
+ val cacheHitCount: Int,
+ val fallbackCount: Int,
+ val avgLatencyMs: Long,
+ val timestamp: Long
+)
+```
+
+**Android 实现**:
+
+```kotlin
+class HTTPDNSReporter(private val reportUrl: String) {
+ private val stats = mutableMapOf()
+ private val reportInterval = 60_000L // 60秒上报一次
+
+ data class MutableStats(
+ var resolveCount: Int = 0,
+ var successCount: Int = 0,
+ var cacheHitCount: Int = 0,
+ var fallbackCount: Int = 0,
+ var totalLatencyMs: Long = 0
+ )
+
+ fun recordResolve(host: String, source: ResolveSource, latencyMs: Long, success: Boolean) {
+ synchronized(stats) {
+ val s = stats.getOrPut(host) { MutableStats() }
+ s.resolveCount++
+ if (success) s.successCount++
+ s.totalLatencyMs += latencyMs
+ when (source) {
+ ResolveSource.CACHE -> s.cacheHitCount++
+ ResolveSource.SYSTEM_DNS -> s.fallbackCount++
+ else -> {}
+ }
+ }
+ }
+
+ fun startPeriodicReport() {
+ CoroutineScope(Dispatchers.IO).launch {
+ while (true) {
+ delay(reportInterval)
+ report()
+ }
+ }
+ }
+
+ private suspend fun report() {
+ val snapshot = synchronized(stats) {
+ val copy = stats.toMap()
+ stats.clear()
+ copy
+ }
+
+ if (snapshot.isEmpty()) return
+
+ val reports = snapshot.map { (host, s) ->
+ HTTPDNSStats(
+ host = host,
+ resolveCount = s.resolveCount,
+ successCount = s.successCount,
+ cacheHitCount = s.cacheHitCount,
+ fallbackCount = s.fallbackCount,
+ avgLatencyMs = if (s.resolveCount > 0) s.totalLatencyMs / s.resolveCount else 0,
+ timestamp = System.currentTimeMillis()
+ )
+ }
+
+ try {
+ httpClient.post(reportUrl) {
+ contentType(ContentType.Application.Json)
+ setBody(reports)
+ }
+ } catch (e: Exception) {
+ Log.e("HTTPDNS", "Report failed: ${e.message}")
+ }
+ }
+}
+```
+
+#### 3.4.6 完整 SDK 集成示例
+
+**Android 初始化**:
+
+```kotlin
+class HTTPDNSManager private constructor(context: Context) {
+ private val cacheManager = HTTPDNSCacheManager(context)
+ private val networkMonitor = NetworkMonitor(context)
+ private val resolver = HTTPDNSResolver(
+ serverUrls = listOf(
+ "https://httpdns1.goedge.cn",
+ "https://httpdns2.goedge.cn"
+ ),
+ cacheManager = cacheManager
+ )
+ private val signer = HTTPDNSSigner(appId = "ge_xxx", appSecret = "xxx")
+ private val reporter = HTTPDNSReporter("https://api.goedge.cn/httpdns/report")
+
+ companion object {
+ @Volatile
+ private var instance: HTTPDNSManager? = null
+
+ fun init(context: Context): HTTPDNSManager {
+ return instance ?: synchronized(this) {
+ instance ?: HTTPDNSManager(context.applicationContext).also { instance = it }
+ }
+ }
+
+ fun get(): HTTPDNSManager = instance ?: throw IllegalStateException("Must call init first")
+ }
+
+ init {
+ // 监听网络变化
+ networkMonitor.onNetworkChanged = {
+ Log.d("HTTPDNS", "Network changed, clearing cache")
+ cacheManager.clear()
+ }
+ networkMonitor.start()
+
+ // 启动监控上报
+ reporter.startPeriodicReport()
+ }
+
+ suspend fun resolve(host: String): List {
+ val startTime = System.currentTimeMillis()
+ val result = resolver.resolve(host)
+ val latency = System.currentTimeMillis() - startTime
+ reporter.recordResolve(host, result.source, latency, result.ips.isNotEmpty())
+ return result.ips
+ }
+
+ fun signRequest(request: Request): Request = signer.sign(request)
+
+ /**
+ * 预解析核心域名(App 启动时调用)
+ */
+ suspend fun prefetch(hosts: List) {
+ hosts.forEach { host ->
+ launch { resolve(host) }
+ }
+ }
+}
+```
+
+**使用示例**:
+
+```kotlin
+// Application.onCreate
+HTTPDNSManager.init(this)
+
+// 预解析
+lifecycleScope.launch {
+ HTTPDNSManager.get().prefetch(listOf(
+ "api.example.com",
+ "cdn.example.com",
+ "img.example.com"
+ ))
+}
+
+// 业务请求
+lifecycleScope.launch {
+ val ips = HTTPDNSManager.get().resolve("api.example.com")
+ if (ips.isNotEmpty()) {
+ val request = Request.Builder()
+ .url("https://${ips[0]}/v1/user")
+ .header("Host", "api.example.com")
+ .build()
+ val signedRequest = HTTPDNSManager.get().signRequest(request)
+ // 发起请求...
+ }
+}
+```
+
+---
+
+## 四、实施计划
+
+### 4.1 任务清单
+
+| 阶段 | 任务 | 工时 |
+|------|------|------|
+| **Phase 1** | 数据库 + DAO + gRPC | 3天 |
+| **Phase 2** | Edge-DNS 接口 | 1天 |
+| **Phase 3** | Edge-Node WAF | 1天 |
+| **Phase 4** | Edge-Admin UI | 2天 |
+| **Phase 5** | SDK 核心 (解析+签名) | 1天 |
+| **Phase 6** | SDK 缓存模块 (内存+持久化+LRU+软过期) | 1天 |
+| **Phase 7** | SDK 网络感知 (切换监听+IPv6检测) | 0.5天 |
+| **Phase 8** | SDK 容错降级 (多节点重试+系统DNS降级) | 0.5天 |
+| **Phase 9** | SDK 监控上报 | 0.5天 |
+| **Phase 10** | SDK 集成测试 + 文档 | 0.5天 |
+| **总计** | | **11天** |
+
+### 4.2 文件变更清单
+
+| 操作 | 文件路径 |
+|------|----------|
+| 新建 | `EdgeDNS/internal/nodes/httpdns.go` |
+| 修改 | `EdgeDNS/internal/nodes/server.go` |
+| 新建 | `EdgeNode/internal/waf/checkpoints/checkpoint_httpdns_fingerprint.go` |
+| 修改 | `EdgeNode/internal/waf/checkpoints/init.go` |
+| 新建 | `EdgeAPI/internal/db/models/httpdns_app_dao.go` |
+| 新建 | `EdgeAPI/internal/rpc/services/service_httpdns_app.go` |
+| 新建 | `EdgeCommon/pkg/rpc/protos/service_httpdns_app.proto` |
+| 新建 | `EdgeAdmin/internal/web/actions/httpdns/*.go` |
+| 新建 | `EdgeAdmin/web/views/httpdns/apps/*.html` |
+
+---
+
+## 五、测试验证
+
+### 5.1 服务端测试
+
+```bash
+# 1. 测试 HTTPDNS 解析
+curl "https://httpdns.example.com/httpdns/resolve?host=api.example.com"
+
+# 2. 测试无签名访问业务(应被拦截)
+curl "https://1.2.3.4/v1/user" -H "Host: api.example.com"
+
+# 3. 测试带签名访问
+curl "https://1.2.3.4/v1/user" \
+ -H "Host: api.example.com" \
+ -H "X-GE-AppID: ge_abc123" \
+ -H "X-GE-Timestamp: 1700000000" \
+ -H "X-GE-Token: "
+```
+
+### 5.2 SDK 测试矩阵
+
+| 模块 | 测试场景 | 预期结果 |
+|------|----------|----------|
+| **缓存** | 首次解析 | 请求服务器,写入缓存 |
+| **缓存** | 缓存有效期内再次解析 | 直接返回缓存,不发请求 |
+| **缓存** | 缓存软过期 | 返回旧数据,后台异步刷新 |
+| **缓存** | 缓存完全过期 | 重新请求服务器 |
+| **缓存** | 缓存超过100条 | LRU 淘汰最旧条目 |
+| **网络感知** | WiFi → 4G 切换 | 清空所有缓存 |
+| **网络感知** | 4G → WiFi 切换 | 清空所有缓存 |
+| **网络感知** | IPv6 检测(双栈网络) | 返回 true |
+| **容错** | 主节点超时 | 自动切换备用节点 |
+| **容错** | 所有节点失败 | 降级到系统 DNS |
+| **容错** | 黑名单域名 | 直接走系统 DNS |
+| **监控** | 60 秒内多次解析 | 统计聚合后上报 |
+| **监控** | 上报失败 | 静默失败,不影响解析 |
+
+### 5.3 Android 单元测试示例
+
+```kotlin
+@Test
+fun `cache hit returns immediately without network request`() = runTest {
+ // Given
+ cacheManager.put("api.example.com", listOf("1.2.3.4"), emptyList(), 600)
+
+ // When
+ val result = resolver.resolve("api.example.com")
+
+ // Then
+ assertEquals(listOf("1.2.3.4"), result.ips)
+ assertEquals(ResolveSource.CACHE, result.source)
+ verify(httpClient, never()).get(any())
+}
+
+@Test
+fun `soft expired cache triggers background refresh`() = runTest {
+ // Given: cache with TTL=1s, soft expire = TTL+60s
+ cacheManager.put("api.example.com", listOf("1.2.3.4"), emptyList(), 1)
+ advanceTimeBy(2000) // TTL expired but within soft expire
+
+ // When
+ val result = resolver.resolve("api.example.com")
+
+ // Then
+ assertEquals(listOf("1.2.3.4"), result.ips) // Returns stale data
+ advanceUntilIdle()
+ verify(httpClient).get(any()) // Background refresh triggered
+}
+
+@Test
+fun `network change clears cache`() = runTest {
+ // Given
+ cacheManager.put("api.example.com", listOf("1.2.3.4"), emptyList(), 600)
+
+ // When
+ networkMonitor.simulateNetworkChange()
+
+ // Then
+ val (cached, _) = cacheManager.get("api.example.com")
+ assertNull(cached)
+}
+
+@Test
+fun `fallback to system DNS on timeout`() = runTest {
+ // Given
+ whenever(httpClient.get(any())).thenThrow(TimeoutException())
+
+ // When
+ val result = resolver.resolve("api.example.com")
+
+ // Then
+ assertEquals(ResolveSource.SYSTEM_DNS, result.source)
+}
+```
+
+---
+
+## 六、上线清单
+
+### 6.1 服务端部署
+
+- [ ] 数据库迁移(edgeHTTPDNSApps 表)
+- [ ] Edge-API 部署
+- [ ] Edge-DNS 部署
+- [ ] Edge-Node 部署(含 WAF 指纹校验)
+- [ ] Edge-Admin 部署(App 管理 UI)
+- [ ] 创建测试 App(获取 AppID/Secret)
+
+### 6.2 SDK 发布
+
+- [ ] Android SDK 单元测试通过
+- [ ] iOS SDK 单元测试通过
+- [ ] Android SDK 集成测试(真机)
+- [ ] iOS SDK 集成测试(真机)
+- [ ] SDK 打包发布(Maven/CocoaPods)
+- [ ] SDK 接入文档发布
+
+### 6.3 SDK 功能验收
+
+- [ ] 缓存命中验证
+- [ ] 软过期刷新验证
+- [ ] LRU 淘汰验证
+- [ ] 网络切换清缓存验证
+- [ ] 多节点切换验证
+- [ ] 系统 DNS 降级验证
+- [ ] 监控数据上报验证
+- [ ] 预解析功能验证
+
diff --git a/HttpDNS SDK 功能设计规范.pdf b/HttpDNS SDK 功能设计规范.pdf
new file mode 100644
index 0000000..99aa321
Binary files /dev/null and b/HttpDNS SDK 功能设计规范.pdf differ
diff --git a/deploy/fluent-bit/README.md b/deploy/fluent-bit/README.md
index 89adcfe..47e64a5 100644
--- a/deploy/fluent-bit/README.md
+++ b/deploy/fluent-bit/README.md
@@ -6,18 +6,20 @@
## Fluent Bit 跑在哪台机器上?
-**Fluent Bit 应部署在每台 EdgeNode 机器上**(与 edge-node 同机),不要部署在 EdgeAPI 机器上。
+**Fluent Bit 应部署在写日志文件的节点机器上**(EdgeNode / EdgeDNS 同机),不要部署在 EdgeAPI 机器上。
-- 日志文件(`/var/log/edge/edge-node/*.log`)是 **EdgeNode** 在本机写的,只有 EdgeNode 所在机器才有这些文件。
-- Fluent Bit 使用 **tail** 插件读取本机路径,因此必须运行在 **有这些日志文件的机器** 上,即每台 EdgeNode。
-- EdgeAPI 机器上没有边缘节点日志,只负责查询 ClickHouse/MySQL,因此不需要在 EdgeAPI 上跑 Fluent Bit。
-- **多台 EdgeNode 时**:每台 EdgeNode 各跑一份 Fluent Bit,各自采集本机日志并上报到同一 ClickHouse。
+- HTTP 日志文件默认在 `/var/log/edge/edge-node/*.log`,由 **EdgeNode** 本机写入;若配置了公用访问日志策略的文件 `path`,节点会优先复用该 `path` 所在目录。
+- DNS 日志文件默认在 `/var/log/edge/edge-dns/*.log`,由 **EdgeDNS** 本机写入;若配置了公用访问日志策略的文件 `path`,节点会优先复用该 `path` 所在目录。
+- Fluent Bit 使用 **tail** 读取本机路径,因此必须运行在这些日志文件所在机器上。
+- EdgeAPI 机器主要负责查询 ClickHouse/MySQL,不需要承担日志采集。
+- 多机部署时,每台写日志节点都跑一份 Fluent Bit,上报到同一 ClickHouse 集群。
---
## 一、前置条件
-- **边缘节点(EdgeNode)** 已开启本地日志落盘,日志目录默认 `/var/log/edge/edge-node`,会生成 `access.log`、`waf.log`、`error.log`(JSON Lines)。由环境变量 `EDGE_LOG_DIR` 控制路径。
+- **边缘节点(EdgeNode)** 已开启本地日志落盘,目录优先取“公用访问日志策略”的文件 `path`(取目录),为空时回退 `EDGE_LOG_DIR`,再回退默认 `/var/log/edge/edge-node`;生成 `access.log`、`waf.log`、`error.log`(JSON Lines)。
+- **DNS 节点(EdgeDNS)** 已开启本地日志落盘,目录优先取“公用访问日志策略”的文件 `path`(取目录),为空时回退 `EDGE_DNS_LOG_DIR`,再回退默认 `/var/log/edge/edge-dns`;生成 `access.log`(JSON Lines)。
- **ClickHouse** 已安装并可访问(单机或集群),且已创建好 `logs_ingest` 表(见下文「五、ClickHouse 建表」)。
- 若 Fluent Bit 与 ClickHouse 不在同一台机,需保证网络可达(默认 HTTP 端口 8123)。
@@ -98,7 +100,10 @@ sudo cp fluent-bit.conf clickhouse-upstream.conf /etc/fluent-bit/
### 3.4 日志路径与 parsers.conf
-- **日志路径**:`fluent-bit.conf` 里 INPUT 的 `Path` 已为 `/var/log/edge/edge-node/*.log`,与 EdgeNode 默认落盘路径一致;若你改了 `EDGE_LOG_DIR`,请同步改此处的 `Path`。
+- **日志路径**:`fluent-bit.conf` 里已同时配置 HTTP 与 DNS 两类路径:
+ - HTTP:`/var/log/edge/edge-node/*.log`
+ - DNS:`/var/log/edge/edge-dns/*.log`
+ 若你配置了公用访问日志策略的文件 `path`,或改了 `EDGE_LOG_DIR` / `EDGE_DNS_LOG_DIR`,请同步修改对应 `Path`。
- **Parsers_File**:主配置引用了 `parsers.conf`。若安装包自带(如 `/etc/fluent-bit/parsers.conf`),无需改动;若启动报错找不到文件,可:
- 从 Fluent Bit 官方仓库复制 [conf/parsers.conf](https://github.com/fluent/fluent-bit/blob/master/conf/parsers.conf) 到同一目录,或
- 在同一目录新建空文件 `parsers.conf`(仅当不使用任何 parser 时)。
@@ -194,7 +199,11 @@ fluent-bit -c /etc/fluent-bit/fluent-bit.conf
## 五、ClickHouse 建表
-平台(EdgeAPI)查询的是表 `logs_ingest`,需在 ClickHouse 中先建表。库名默认为 `default`,若使用其它库,需与 EdgeAPI 的 `CLICKHOUSE_DATABASE` 一致。
+平台(EdgeAPI)会查询两张表:
+- HTTP:`logs_ingest`
+- DNS:`dns_logs_ingest`
+
+需在 ClickHouse 中先建表。库名默认为 `default`,若使用其它库,需与 EdgeAPI 的 `CLICKHOUSE_DATABASE` 一致。
在 ClickHouse 中执行(按需改库名或引擎):
@@ -231,6 +240,34 @@ ORDER BY (timestamp, node_id, server_id, trace_id)
SETTINGS index_granularity = 8192;
```
+DNS 日志建表:
+
+```sql
+CREATE TABLE IF NOT EXISTS default.dns_logs_ingest
+(
+ timestamp DateTime,
+ request_id String,
+ node_id UInt64,
+ cluster_id UInt64,
+ domain_id UInt64,
+ record_id UInt64,
+ remote_addr String,
+ question_name String,
+ question_type String,
+ record_name String,
+ record_type String,
+ record_value String,
+ networking String,
+ is_recursive UInt8,
+ error String,
+ ns_route_codes Array(String),
+ content_json String DEFAULT ''
+)
+ENGINE = MergeTree()
+ORDER BY (timestamp, request_id, node_id)
+SETTINGS index_granularity = 8192;
+```
+
- **log_type**:`access` / `waf` / `error`;攻击日志同时看 **firewall_rule_id** 或 **firewall_policy_id** 是否大于 0(与原有 MySQL 通过规则 ID 判断攻击日志一致)。
- **request_headers / response_headers**:JSON 字符串;**request_body / response_body**:请求/响应体(单条建议限制长度,如 512KB)。
- **request_body 为空**:需在管理端为该站点/服务的「访问日志」策略中勾选「请求Body」后才会落盘;默认未勾选。路径大致为:站点/服务 → 访问日志 → 策略 → 记录字段 → 勾选「请求Body」。WAF 拦截且策略开启「记录请求Body」时也会记录。
@@ -248,6 +285,7 @@ ALTER TABLE default.logs_ingest ADD COLUMN IF NOT EXISTS request_headers String
ALTER TABLE default.logs_ingest ADD COLUMN IF NOT EXISTS request_body String DEFAULT '';
ALTER TABLE default.logs_ingest ADD COLUMN IF NOT EXISTS response_headers String DEFAULT '';
ALTER TABLE default.logs_ingest ADD COLUMN IF NOT EXISTS response_body String DEFAULT '';
+ALTER TABLE default.dns_logs_ingest ADD COLUMN IF NOT EXISTS content_json String DEFAULT '';
```
Fluent Bit 写入时使用 `json_date_key timestamp` 和 `json_date_format epoch`,会将 JSON 中的 `timestamp`(Unix 秒)转为 DateTime。
@@ -264,13 +302,15 @@ Fluent Bit 写入时使用 `json_date_key timestamp` 和 `json_date_format epoch
```sql
SELECT count() FROM default.logs_ingest;
SELECT * FROM default.logs_ingest LIMIT 5;
+ SELECT count() FROM default.dns_logs_ingest;
+ SELECT * FROM default.dns_logs_ingest LIMIT 5;
```
3. **常见问题**
- **连接被拒**:检查 `clickhouse-upstream.conf` 的 Host/Port、防火墙、ClickHouse 的 `listen_host`。
- **认证失败**:检查 `CH_USER`、`CH_PASSWORD` 是否与 ClickHouse 用户一致,环境变量是否被 systemd 正确加载。
- **找不到 parsers.conf**:见上文 3.4。
- - **没有新数据**:确认 EdgeNode 已写日志到 `Path` 下,且 Fluent Bit 对该目录有读权限;可用 `tail -f /var/log/edge/edge-node/access.log` 观察是否有新行。
+ - **没有新数据**:确认 EdgeNode/EdgeDNS 已写日志到 `Path` 下,且 Fluent Bit 对目录有读权限;可分别执行 `tail -f /var/log/edge/edge-node/access.log` 与 `tail -f /var/log/edge/edge-dns/access.log`。
- **Node 上没有 `/var/log/edge/edge-node/access.log`**:见下文「八、Node 上找不到日志文件」。
---
@@ -279,7 +319,8 @@ Fluent Bit 写入时使用 `json_date_key timestamp` 和 `json_date_format epoch
| 组件 | 说明 |
|------|------|
-| **EdgeNode** | 日志落盘路径由 `EDGE_LOG_DIR` 控制,默认 `/var/log/edge/edge-node`;生成 `access.log`、`waf.log`、`error.log`;支持 SIGHUP 重开句柄,可与 logrotate 的 `copytruncate` 配合。 |
+| **EdgeNode** | 日志落盘路径优先复用公用访问日志策略文件 `path`(取目录);若为空回退 `EDGE_LOG_DIR`,再回退默认 `/var/log/edge/edge-node`;生成 `access.log`、`waf.log`、`error.log`;支持 SIGHUP 重开句柄,可与 logrotate 的 `copytruncate` 配合。 |
+| **EdgeDNS** | DNS 访问日志落盘路径优先复用公用访问日志策略文件 `path`(取目录);若为空回退 `EDGE_DNS_LOG_DIR`,再回退默认 `/var/log/edge/edge-dns`;生成 `access.log`(JSON Lines),由 Fluent Bit 采集写入 `dns_logs_ingest`。 |
| **logrotate** | 使用 `deploy/fluent-bit/logrotate.conf` 示例做轮转,避免磁盘占满。 |
| **平台(EdgeAPI)** | 配置 ClickHouse 只读连接(`CLICKHOUSE_HOST`、`CLICKHOUSE_PORT`、`CLICKHOUSE_USER`、`CLICKHOUSE_PASSWORD`、`CLICKHOUSE_DATABASE`);当请求带 `Day` 且已配置 ClickHouse 时,访问日志列表查询走 ClickHouse。 |
@@ -303,6 +344,6 @@ Fluent Bit 写入时使用 `json_date_key timestamp` 和 `json_date_format epoch
新版本在**首次成功加载节点配置后**会调用 `EnsureInit()`,自动创建 `/var/log/edge/edge-node` 及 `access.log`、`waf.log`、`error.log`。重启一次 edge-node 后再看目录下是否已有文件。
4. **自定义路径**
- 若通过环境变量 `EDGE_LOG_DIR` 指定了其它目录,则日志在该目录下;Fluent Bit 的 `Path` 需与之一致。
+ 若在管理端设置了公用访问日志策略的文件 `path`,节点会优先使用该目录;否则才使用 `EDGE_LOG_DIR`。Fluent Bit 的 `Path` 需与实际目录一致。
以上完成即完成 Fluent Bit 的部署与验证。
diff --git a/deploy/fluent-bit/fluent-bit-dns.conf b/deploy/fluent-bit/fluent-bit-dns.conf
new file mode 100644
index 0000000..3a101b3
--- /dev/null
+++ b/deploy/fluent-bit/fluent-bit-dns.conf
@@ -0,0 +1,36 @@
+# DNS 节点专用:使用 HTTP 输出写入 ClickHouse(无需 out_clickhouse 插件)
+# 启动前设置:CH_USER、CH_PASSWORD;若 ClickHouse 不在本机,请修改 Host、Port
+# Read_from_Head=true:首次启动会发送已有日志;若只采新日志建议改为 false
+
+[SERVICE]
+ Flush 5
+ Log_Level info
+ Parsers_File parsers.conf
+ storage.path /var/lib/fluent-bit/storage
+ storage.sync normal
+ storage.checksum off
+ storage.backlog.mem_limit 128MB
+
+[INPUT]
+ Name tail
+ Path /var/log/edge/edge-dns/*.log
+ Tag app.dns.logs
+ Parser json
+ Refresh_Interval 5
+ Read_from_Head false
+ DB /var/lib/fluent-bit/dns-logs.db
+ Mem_Buf_Limit 128MB
+ Skip_Long_Lines On
+
+[OUTPUT]
+ Name http
+ Match app.dns.logs
+ Host 127.0.0.1
+ Port 8123
+ URI /?query=INSERT%20INTO%20default.dns_logs_ingest%20FORMAT%20JSONEachRow
+ Format json_lines
+ http_user ${CH_USER}
+ http_passwd ${CH_PASSWORD}
+ json_date_key timestamp
+ json_date_format epoch
+ Retry_Limit 10
diff --git a/deploy/fluent-bit/fluent-bit.conf b/deploy/fluent-bit/fluent-bit.conf
index e5340e6..06a454c 100644
--- a/deploy/fluent-bit/fluent-bit.conf
+++ b/deploy/fluent-bit/fluent-bit.conf
@@ -1,5 +1,6 @@
# Fluent Bit 主配置(边缘节点日志采集 → ClickHouse)
-# 生产环境将 INPUT 改为 tail 采集 /var/log/edge/edge-node/*.log
+# HTTP: /var/log/edge/edge-node/*.log
+# DNS: /var/log/edge/edge-dns/*.log
[SERVICE]
Flush 5
@@ -15,16 +16,26 @@
[INPUT]
Name tail
Path /var/log/edge/edge-node/*.log
- Tag app.logs
+ Tag app.http.logs
Refresh_Interval 5
Read_from_Head false
- DB /var/lib/fluent-bit/logs.db
+ DB /var/lib/fluent-bit/http-logs.db
+ Mem_Buf_Limit 128MB
+ Skip_Long_Lines On
+
+[INPUT]
+ Name tail
+ Path /var/log/edge/edge-dns/*.log
+ Tag app.dns.logs
+ Refresh_Interval 5
+ Read_from_Head false
+ DB /var/lib/fluent-bit/dns-logs.db
Mem_Buf_Limit 128MB
Skip_Long_Lines On
[OUTPUT]
Name clickhouse
- Match *
+ Match app.http.logs
Upstream ch_backends
Table logs_ingest
Http_User ${CH_USER}
@@ -32,3 +43,14 @@
json_date_key timestamp
json_date_format epoch
Retry_Limit 10
+
+[OUTPUT]
+ Name clickhouse
+ Match app.dns.logs
+ Upstream ch_backends
+ Table dns_logs_ingest
+ Http_User ${CH_USER}
+ Http_Passwd ${CH_PASSWORD}
+ json_date_key timestamp
+ json_date_format epoch
+ Retry_Limit 10
diff --git a/deploy/fluent-bit/logrotate.conf b/deploy/fluent-bit/logrotate.conf
index ff307f9..8daedf9 100644
--- a/deploy/fluent-bit/logrotate.conf
+++ b/deploy/fluent-bit/logrotate.conf
@@ -9,3 +9,12 @@
notifempty
copytruncate
}
+
+/var/log/edge/edge-dns/*.log {
+ daily
+ rotate 14
+ compress
+ missingok
+ notifempty
+ copytruncate
+}
diff --git a/go1.21.6.linux-amd64.tar.gz b/go1.21.6.linux-amd64.tar.gz
new file mode 100644
index 0000000..e6567ae
Binary files /dev/null and b/go1.21.6.linux-amd64.tar.gz differ
diff --git a/go1.25.7.linux-amd64.tar.gz b/go1.25.7.linux-amd64.tar.gz
new file mode 100644
index 0000000..4a4ae60
Binary files /dev/null and b/go1.25.7.linux-amd64.tar.gz differ
diff --git a/编译部署升级策略.md b/编译部署升级策略.md
new file mode 100644
index 0000000..e3e0eaf
--- /dev/null
+++ b/编译部署升级策略.md
@@ -0,0 +1,232 @@
+# waf-platform 编译、部署、升级策略(WSL Ubuntu 22.04)
+
+## 1. 适用范围
+
+- 主基线:`E:\AI_PRODUCT\waf-platform`(不是 `waf-platform-1.4.5/1.4.6`)。
+- 本手册覆盖:
+ - `EdgeAdmin` / `EdgeAPI` / `EdgeNode` / `EdgeDNS`
+ - HTTP + DNS 访问日志策略
+ - Fluent Bit + ClickHouse 日志链路
+
+---
+
+## 2. 关键结论(先看)
+
+1. 用 `EdgeAdmin/build/build.sh` 编译时,会联动编译 `EdgeAPI`,并由 `EdgeAPI` 联动编译 `EdgeNode`。
+2. `EdgeDNS` 只有在 `plus` 模式下才会被 `EdgeAPI/build/build.sh` 自动编译并放入 deploy。
+3. 当前脚本已临时关闭自动 `arm64` 编译,只保留 `amd64` 自动链路。
+3. 如果你要发布“本次所有改动”(含 DNS/ClickHouse),建议统一用:
+ ```bash
+ cd /mnt/e/AI_PRODUCT/waf-platform/EdgeAdmin/build
+ bash build.sh linux amd64 plus
+ ```
+4. DNS 节点与 Node 节点分离部署时,两边都要有 Fluent Bit(各自采集本机日志)。
+
+---
+
+## 3. 编译前检查
+
+在 WSL Ubuntu 22.04 执行:
+
+```bash
+cd /mnt/e/AI_PRODUCT/waf-platform
+git rev-parse --short HEAD
+go version
+which zip unzip go find sed
+```
+
+建议:
+
+- 线上 Ubuntu 22.04,尽量也在 Ubuntu 22.04 编译,避免 `GLIBC`/`GLIBCXX` 不兼容。
+- 若 Node plus 使用 cgo/libpcap/libbrotli,请确保构建机依赖完整。
+
+---
+
+## 4. 一键编译(推荐)
+
+```bash
+cd /mnt/e/AI_PRODUCT/waf-platform/EdgeAdmin/build
+bash build.sh linux amd64 plus
+```
+
+### 4.1 此命令会做什么
+
+- 编译 `EdgeAdmin`
+- 自动调用 `EdgeAPI/build/build.sh`
+- `EdgeAPI` 自动编译并打包 `EdgeNode`(当前仅 linux/amd64)
+- `plus` 模式下,`EdgeAPI` 自动编译并打包 `EdgeDNS`(当前仅 linux/amd64)
+- 把 node/dns 包放入 API 的 `deploy` 目录用于远程安装
+
+### 4.2 主要产物位置
+
+- Admin 包:`EdgeAdmin/dist/edge-admin-linux-amd64-v*.zip`
+- API 包:`EdgeAPI/dist/edge-api-linux-amd64-v*.zip`
+- Node 包:`EdgeNode/dist/edge-node-linux-*.zip`
+- DNS 包:`EdgeDNS/dist/edge-dns-linux-*.zip`(plus 时)
+- API deploy 安装包目录:`EdgeAPI/build/deploy/`
+
+---
+
+## 5. 是否需要单独编译 API / DNS / Node
+
+### 5.1 不需要单独编译 API 的场景
+
+- 你已经执行 `EdgeAdmin/build/build.sh ... plus`,且要发布整套改动。
+
+### 5.2 需要单独编译的场景
+
+- 只改了 API,不想重新打 Admin:
+ ```bash
+ cd /mnt/e/AI_PRODUCT/waf-platform/EdgeAPI/build
+ bash build.sh linux amd64 plus
+ ```
+- 只改了 Node:
+ ```bash
+ cd /mnt/e/AI_PRODUCT/waf-platform/EdgeNode/build
+ bash build.sh linux amd64 plus
+ ```
+- 只改了 DNS:
+ ```bash
+ cd /mnt/e/AI_PRODUCT/waf-platform/EdgeDNS/build
+ bash build.sh linux amd64
+ ```
+
+---
+
+## 6. 升级顺序(生产建议)
+
+## 6.1 第一步:先改 ClickHouse(DDL)
+
+先在 ClickHouse 建/改表,至少包含:
+
+- `logs_ingest`(HTTP)
+- `dns_logs_ingest`(DNS)
+
+先做 DDL 的原因:避免新版本写入时目标表不存在。
+
+## 6.2 第二步:部署 Fluent Bit 配置
+
+### Node 节点(HTTP)
+
+- 配置文件目录一般是 `/etc/fluent-bit/`
+- 至少更新:
+ - `fluent-bit.conf`(或你实际启用的 `fluent-bit-http.conf`)
+ - `clickhouse-upstream.conf`
+ - `parsers.conf`(通常可复用)
+
+### DNS 节点(DNS)
+
+- DNS 节点若之前没装 Fluent Bit,需要先安装并创建 service。
+ - `curl https://raw.githubusercontent.com/fluent/fluent-bit/master/install.sh | sh`
+ - `sudo apt-get update`
+ - `sudo apt-get install -y fluent-bit`
+- 建议同样用 `/etc/fluent-bit/`,放:
+ - `fluent-bit.conf`(DNS 版本或含 DNS INPUT/OUTPUT 的统一版本)
+ - `clickhouse-upstream.conf`
+ - `parsers.conf`
+
+重启:
+
+```bash
+sudo systemctl restart fluent-bit
+sudo systemctl status fluent-bit
+```
+
+## 6.3 第三步:升级管理面(API + Admin)
+
+在管理节点更新 `edge-api`、`edge-admin` 包并重启对应服务。
+./bin/edge-api status
+./bin/edge-api restart
+
+## 6.4 第四步:升级数据面(Node / DNS)
+
+- 通过 API 的远程安装/升级流程分批升级 Node、DNS
+- 或手工替换二进制后重启服务
+
+## 6.5 第五步:最后切换日志策略
+
+在页面启用目标策略(MySQL only / ClickHouse only / 双写),并验证读写链路。
+
+---
+
+## 7. 日志策略与读写行为(当前实现)
+
+## 7.1 HTTP / DNS 共用语义
+
+- `WriteMySQL=true`:写 MySQL(通过 API)
+- `WriteClickHouse=true`:写本地日志文件,由 Fluent Bit 异步采集进 CH
+- 两者都开:双写
+- 两者都关:不写
+
+## 7.2 查询侧优先级
+
+- 优先读 ClickHouse(可用且策略允许)
+- ClickHouse 异常时按策略回退 MySQL
+- 若两边都不可读,返回空
+
+## 7.3 关于“日志文件路径”
+
+- 现在前端已调整:当存储类型包含 ClickHouse 时,创建/编辑页隐藏“日志文件路径”输入。
+- 但 Fluent Bit 的 `Path` 必须匹配实际日志目录;若你改了日志目录,需要同步改 Fluent Bit 配置并重启。
+
+---
+
+## 8. 服务检查与常用命令
+
+## 8.1 检查 Fluent Bit 服务名
+
+```bash
+systemctl list-unit-files | grep -Ei 'fluent|td-agent-bit'
+systemctl status fluent-bit.service
+```
+
+## 8.2 查看 Fluent Bit 实际使用的配置文件
+
+```bash
+systemctl status fluent-bit.service
+```
+
+重点看 `ExecStart`,例如:
+
+```text
+/opt/fluent-bit/bin/fluent-bit -c /etc/fluent-bit/fluent-bit.conf
+```
+
+## 8.3 验证 ClickHouse 是否有数据
+
+```sql
+SELECT count() FROM default.logs_ingest;
+SELECT count() FROM default.dns_logs_ingest;
+```
+
+---
+
+## 9. 回滚策略(最小影响)
+
+1. 先把页面日志策略切回 MySQL-only。
+2. 回滚 API/Admin 到上一版本。
+3. Node/DNS 分批回滚。
+4. Fluent Bit 保留运行不影响主业务(只停止 CH 写入即可)。
+
+---
+
+## 10. 一次发布的最简执行清单
+
+```bash
+# 1) 构建
+cd /mnt/e/AI_PRODUCT/waf-platform/EdgeAdmin/build
+bash build.sh linux amd64 plus
+
+# 2) 上传产物
+# EdgeAdmin/dist/*.zip
+# EdgeAPI/dist/*.zip
+# EdgeAPI/build/deploy/* (node/dns installer zip)
+
+# 3) 线上先执行 CH DDL
+# 4) 更新 fluent-bit 配置并重启
+sudo systemctl restart fluent-bit
+
+# 5) 升级 edge-api / edge-admin 并重启
+# 6) 升级 edge-node / edge-dns
+# 7) 切日志策略并验证
+```
diff --git a/访问日志策略配置手册.md b/访问日志策略配置手册.md
index ecf79ea..7b30086 100644
--- a/访问日志策略配置手册.md
+++ b/访问日志策略配置手册.md
@@ -139,3 +139,51 @@ flowchart LR
- 通常 1 分钟内自动刷新生效。
- 若要立即生效:重启 `edge-api`,并在需要时重启 `edge-node`、`fluent-bit`。
+---
+
+## 8. DNS 日志与 HTTP 策略联动(新增)
+
+从当前版本开始,DNS 访问日志与 HTTP 访问日志共享同一套公用策略语义(`writeTargets`):
+
+- `WriteMySQL=true`:DNS 节点上报 API,API 写入 MySQL 分表。
+- `WriteClickHouse=true`:DNS 节点写本地 JSONL,Fluent Bit 采集写入 ClickHouse `dns_logs_ingest`。
+- 双开即双写;双关即不写(仅保留内存处理,不入库)。
+
+### 8.1 DNS 写入链路
+
+```mermaid
+flowchart LR
+ A[EdgeDNS 产生日志] --> B{writeTargets}
+ B -->|MySQL=true| C[CreateNSAccessLogs]
+ C --> D[(MySQL edgeNSAccessLogs_YYYYMMDD)]
+ B -->|ClickHouse=true| E[/var/log/edge/edge-dns/access.log]
+ E --> F[Fluent Bit]
+ F --> G[(ClickHouse dns_logs_ingest)]
+```
+
+### 8.2 DNS 查询链路
+
+```mermaid
+flowchart TD
+ Q[/ns/clusters/accessLogs] --> R{策略是否启用ClickHouse且CH可用}
+ R -->|是| CH[(dns_logs_ingest)]
+ R -->|否| M{策略是否启用MySQL}
+ CH -->|查询失败| M
+ M -->|是| MY[(MySQL edgeNSAccessLogs_YYYYMMDD)]
+ M -->|否| E[返回空列表]
+```
+
+### 8.3 组合场景说明(DNS)
+
+| 策略 | 写入 | 读取 |
+|------|------|------|
+| 仅 MySQL | API -> MySQL | MySQL |
+| 仅 ClickHouse | 本地文件 -> Fluent Bit -> ClickHouse | ClickHouse |
+| MySQL + ClickHouse | API -> MySQL + 本地文件 -> Fluent Bit -> ClickHouse | 优先 ClickHouse,失败回退 MySQL |
+
+### 8.4 DNS 相关必须配置
+
+1. `EdgeAPI` 配置 ClickHouse 连接(仅读 CH 时必须)。
+2. `deploy/fluent-bit/fluent-bit.conf` 已包含 DNS 输入:`/var/log/edge/edge-dns/*.log`。
+3. ClickHouse 已创建 `dns_logs_ingest` 表。
+4. EdgeDNS 运行用户对 `EDGE_DNS_LOG_DIR`(默认 `/var/log/edge/edge-dns`)有写权限。