diff --git a/.gitattributes b/.gitattributes index 2e58a92..2e81367 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,3 @@ *.sh text eol=lf *.bash text eol=lf -*.yaml text eol=lf -*.yml text eol=lf -Dockerfile text eol=lf +*.zsh text eol=lf diff --git a/.gitignore b/.gitignore index eb9f4ce..fa84c93 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ deploy/fluent-bit/logs.db deploy/fluent-bit/logs.db-shm deploy/fluent-bit/logs.db-wal deploy/fluent-bit/storage/ +/pkg/ +/.claude/ + +# Local large build artifacts +EdgeAdmin/edge-admin.exe +EdgeAPI/deploy/edge-node-linux-amd64-v*.zip \ No newline at end of file diff --git a/EdgeAPI/build/build.sh b/EdgeAPI/build/build.sh index dade19e..9c58075 100644 --- a/EdgeAPI/build/build.sh +++ b/EdgeAPI/build/build.sh @@ -94,6 +94,32 @@ function build() { fi fi + # build edge-httpdns + HTTPDNS_ROOT=$ROOT"/../../EdgeHttpDNS" + if [ -d "$HTTPDNS_ROOT" ]; then + HTTPDNSNodeVersion=$(lookup-version "$ROOT""/../../EdgeHttpDNS/internal/const/const.go") + echo "building edge-httpdns v${HTTPDNSNodeVersion} ..." + EDGE_HTTPDNS_NODE_BUILD_SCRIPT=$ROOT"/../../EdgeHttpDNS/build/build.sh" + if [ ! -f "$EDGE_HTTPDNS_NODE_BUILD_SCRIPT" ]; then + echo "unable to find edge-httpdns build script 'EdgeHttpDNS/build/build.sh'" + exit + fi + cd "$ROOT""/../../EdgeHttpDNS/build" || exit + echo "==============================" + architects=("amd64") + #architects=("amd64" "arm64") + for arch in "${architects[@]}"; do + # always rebuild to avoid reusing stale zip when version number is unchanged + ./build.sh linux "$arch" + done + echo "==============================" + cd - || exit + + for arch in "${architects[@]}"; do + cp "$ROOT""/../../EdgeHttpDNS/dist/edge-httpdns-linux-${arch}-v${HTTPDNSNodeVersion}.zip" "$ROOT"/deploy/edge-httpdns-linux-"${arch}"-v"${HTTPDNSNodeVersion}".zip + done + fi + # build sql if [ $TAG = "plus" ]; then echo "building sql ..." diff --git a/EdgeAPI/build/sql.sh b/EdgeAPI/build/sql.sh index 347659a..76fd238 100644 --- a/EdgeAPI/build/sql.sh +++ b/EdgeAPI/build/sql.sh @@ -3,5 +3,10 @@ # generate 'internal/setup/sql.json' file CWD="$(dirname "$0")" +SQL_JSON="${CWD}/../internal/setup/sql.json" -go run "${CWD}"/../cmd/sql-dump/main.go -dir="${CWD}" \ No newline at end of file +if [ -f "$SQL_JSON" ]; then + echo "sql.json already exists, skipping sql-dump (delete it manually to regenerate)" +else + go run "${CWD}"/../cmd/sql-dump/main.go -dir="${CWD}" +fi \ No newline at end of file diff --git a/EdgeAPI/cmd/sql-dump/main.go b/EdgeAPI/cmd/sql-dump/main.go index d201b43..0b819d0 100644 --- a/EdgeAPI/cmd/sql-dump/main.go +++ b/EdgeAPI/cmd/sql-dump/main.go @@ -12,6 +12,8 @@ import ( ) func main() { + Tea.Env = "prod" + db, err := dbs.Default() if err != nil { fmt.Println("[ERROR]" + err.Error()) diff --git a/EdgeAPI/internal/clickhouse/client.go b/EdgeAPI/internal/clickhouse/client.go index 96fe903..dba0895 100644 --- a/EdgeAPI/internal/clickhouse/client.go +++ b/EdgeAPI/internal/clickhouse/client.go @@ -1,6 +1,7 @@ package clickhouse import ( + "bytes" "context" "crypto/tls" "encoding/json" @@ -12,13 +13,11 @@ import ( "time" ) -// Client 通过 HTTP 接口执行只读查询(SELECT),返回 JSONEachRow 解析为 map 或结构体 type Client struct { cfg *Config httpCli *http.Client } -// NewClient 使用共享配置创建客户端 func NewClient() *Client { cfg := SharedConfig() transport := &http.Transport{} @@ -28,6 +27,7 @@ func NewClient() *Client { ServerName: cfg.TLSServerName, } } + return &Client{ cfg: cfg, httpCli: &http.Client{ @@ -37,21 +37,20 @@ func NewClient() *Client { } } -// IsConfigured 是否已配置 func (c *Client) IsConfigured() bool { return c.cfg != nil && c.cfg.IsConfigured() } -// Query 执行 SELECT,将每行 JSON 解析到 dest 切片;dest 元素类型需为 *struct 或 map func (c *Client) Query(ctx context.Context, query string, dest interface{}) error { if !c.IsConfigured() { return fmt.Errorf("clickhouse: not configured") } - // 强制 JSONEachRow 便于解析 + q := strings.TrimSpace(query) if !strings.HasSuffix(strings.ToUpper(q), "FORMAT JSONEACHROW") { query = q + " FORMAT JSONEachRow" } + u := c.buildURL(query) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { @@ -60,28 +59,32 @@ func (c *Client) Query(ctx context.Context, query string, dest interface{}) erro if c.cfg.User != "" || c.cfg.Password != "" { req.SetBasicAuth(c.cfg.User, c.cfg.Password) } + resp, err := c.httpCli.Do(req) if err != nil { return err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("clickhouse HTTP %d: %s", resp.StatusCode, string(body)) } + dec := json.NewDecoder(resp.Body) return decodeRows(dec, dest) } -// QueryRow 执行仅返回一行的查询,将结果解析到 dest(*struct 或 *map) func (c *Client) QueryRow(ctx context.Context, query string, dest interface{}) error { if !c.IsConfigured() { return fmt.Errorf("clickhouse: not configured") } + q := strings.TrimSpace(query) if !strings.HasSuffix(strings.ToUpper(q), "FORMAT JSONEACHROW") { query = q + " FORMAT JSONEachRow" } + u := c.buildURL(query) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { @@ -90,32 +93,109 @@ func (c *Client) QueryRow(ctx context.Context, query string, dest interface{}) e if c.cfg.User != "" || c.cfg.Password != "" { req.SetBasicAuth(c.cfg.User, c.cfg.Password) } + resp, err := c.httpCli.Do(req) if err != nil { return err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("clickhouse HTTP %d: %s", resp.StatusCode, string(body)) } + dec := json.NewDecoder(resp.Body) return decodeOneRow(dec, dest) } +func (c *Client) Execute(ctx context.Context, query string) error { + if !c.IsConfigured() { + return fmt.Errorf("clickhouse: not configured") + } + + u := c.buildURL(strings.TrimSpace(query)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil) + if err != nil { + return err + } + if c.cfg.User != "" || c.cfg.Password != "" { + req.SetBasicAuth(c.cfg.User, c.cfg.Password) + } + + resp, err := c.httpCli.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("clickhouse HTTP %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +func (c *Client) InsertJSONEachRow(ctx context.Context, insertSQL string, rows []map[string]interface{}) error { + if len(rows) == 0 { + return nil + } + if !c.IsConfigured() { + return fmt.Errorf("clickhouse: not configured") + } + + query := strings.TrimSpace(insertSQL) + if !strings.HasSuffix(strings.ToUpper(query), "FORMAT JSONEACHROW") { + query += " FORMAT JSONEachRow" + } + + var payload bytes.Buffer + for _, row := range rows { + if row == nil { + continue + } + data, err := json.Marshal(row) + if err != nil { + return err + } + payload.Write(data) + payload.WriteByte('\n') + } + + u := c.buildURL(query) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, &payload) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if c.cfg.User != "" || c.cfg.Password != "" { + req.SetBasicAuth(c.cfg.User, c.cfg.Password) + } + + resp, err := c.httpCli.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("clickhouse HTTP %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + func (c *Client) buildURL(query string) string { scheme := "http" if c.cfg != nil && strings.EqualFold(c.cfg.Scheme, "https") { scheme = "https" } - rawURL := fmt.Sprintf("%s://%s:%d/?query=%s&database=%s", + return fmt.Sprintf("%s://%s:%d/?query=%s&database=%s", scheme, c.cfg.Host, c.cfg.Port, url.QueryEscape(query), url.QueryEscape(c.cfg.Database)) - return rawURL } -// decodeRows 将 JSONEachRow 流解析到 slice;元素类型须为 *struct 或 *[]map[string]interface{} func decodeRows(dec *json.Decoder, dest interface{}) error { - // dest 应为 *[]*SomeStruct 或 *[]map[string]interface{} switch d := dest.(type) { case *[]map[string]interface{}: *d = (*d)[:0] @@ -130,7 +210,7 @@ func decodeRows(dec *json.Decoder, dest interface{}) error { *d = append(*d, row) } default: - return fmt.Errorf("clickhouse: unsupported dest type for Query (use *[]map[string]interface{} or implement decoder)") + return fmt.Errorf("clickhouse: unsupported dest type for Query (use *[]map[string]interface{})") } } diff --git a/EdgeAPI/internal/clickhouse/config.go b/EdgeAPI/internal/clickhouse/config.go index c2fe014..31add08 100644 --- a/EdgeAPI/internal/clickhouse/config.go +++ b/EdgeAPI/internal/clickhouse/config.go @@ -12,15 +12,15 @@ import ( ) const ( - envHost = "CLICKHOUSE_HOST" - envPort = "CLICKHOUSE_PORT" - envUser = "CLICKHOUSE_USER" - envPassword = "CLICKHOUSE_PASSWORD" - envDatabase = "CLICKHOUSE_DATABASE" - envScheme = "CLICKHOUSE_SCHEME" - defaultPort = 8443 - defaultDB = "default" - defaultScheme = "https" + envHost = "CLICKHOUSE_HOST" + envPort = "CLICKHOUSE_PORT" + envUser = "CLICKHOUSE_USER" + envPassword = "CLICKHOUSE_PASSWORD" + envDatabase = "CLICKHOUSE_DATABASE" + envScheme = "CLICKHOUSE_SCHEME" + defaultPort = 8443 + defaultDB = "default" + defaultScheme = "https" ) var ( diff --git a/EdgeAPI/internal/clickhouse/httpdns_access_logs_store.go b/EdgeAPI/internal/clickhouse/httpdns_access_logs_store.go new file mode 100644 index 0000000..a794c5c --- /dev/null +++ b/EdgeAPI/internal/clickhouse/httpdns_access_logs_store.go @@ -0,0 +1,294 @@ +package clickhouse + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" +) + +const httpDNSAccessLogsTable = "httpdns_access_logs_ingest" + +type HTTPDNSAccessLogRow struct { + RequestId string + ClusterId int64 + NodeId int64 + AppId string + AppName string + Domain string + QType string + ClientIP string + ClientRegion string + Carrier string + SDKVersion string + OS string + ResultIPs string + Status string + ErrorCode string + CostMs int32 + CreatedAt int64 + Day string + Summary string +} + +type HTTPDNSAccessLogListFilter struct { + Day string + ClusterId int64 + NodeId int64 + AppId string + AppIds []string + Domain string + Status string + Keyword string + Offset int64 + Size int64 +} + +type HTTPDNSAccessLogsStore struct { + client *Client +} + +func NewHTTPDNSAccessLogsStore() *HTTPDNSAccessLogsStore { + return &HTTPDNSAccessLogsStore{client: NewClient()} +} + +func (s *HTTPDNSAccessLogsStore) Client() *Client { + return s.client +} + +func (s *HTTPDNSAccessLogsStore) Insert(ctx context.Context, logs []*pb.HTTPDNSAccessLog) error { + if len(logs) == 0 { + return nil + } + if !s.client.IsConfigured() { + return fmt.Errorf("clickhouse: not configured") + } + + rows := make([]map[string]interface{}, 0, len(logs)) + for _, item := range logs { + if item == nil { + continue + } + rows = append(rows, map[string]interface{}{ + "request_id": item.GetRequestId(), + "cluster_id": item.GetClusterId(), + "node_id": item.GetNodeId(), + "app_id": item.GetAppId(), + "app_name": item.GetAppName(), + "domain": item.GetDomain(), + "qtype": item.GetQtype(), + "client_ip": item.GetClientIP(), + "client_region": item.GetClientRegion(), + "carrier": item.GetCarrier(), + "sdk_version": item.GetSdkVersion(), + "os": item.GetOs(), + "result_ips": item.GetResultIPs(), + "status": item.GetStatus(), + "error_code": item.GetErrorCode(), + "cost_ms": item.GetCostMs(), + "created_at": item.GetCreatedAt(), + "day": item.GetDay(), + "summary": item.GetSummary(), + }) + } + + query := fmt.Sprintf("INSERT INTO %s (request_id, cluster_id, node_id, app_id, app_name, domain, qtype, client_ip, client_region, carrier, sdk_version, os, result_ips, status, error_code, cost_ms, created_at, day, summary)", + s.tableName()) + return s.client.InsertJSONEachRow(ctx, query, rows) +} + +func (s *HTTPDNSAccessLogsStore) Count(ctx context.Context, f HTTPDNSAccessLogListFilter) (int64, error) { + if !s.client.IsConfigured() { + return 0, fmt.Errorf("clickhouse: not configured") + } + + conditions := s.buildConditions(f) + query := fmt.Sprintf("SELECT count() AS count FROM %s", s.tableName()) + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + + row := map[string]interface{}{} + if err := s.client.QueryRow(ctx, query, &row); err != nil { + return 0, err + } + return toInt64(row["count"]), nil +} + +func (s *HTTPDNSAccessLogsStore) List(ctx context.Context, f HTTPDNSAccessLogListFilter) ([]*HTTPDNSAccessLogRow, error) { + if !s.client.IsConfigured() { + return nil, fmt.Errorf("clickhouse: not configured") + } + + size := f.Size + if size <= 0 { + size = 20 + } + if size > 1000 { + size = 1000 + } + offset := f.Offset + if offset < 0 { + offset = 0 + } + + conditions := s.buildConditions(f) + query := fmt.Sprintf("SELECT request_id, cluster_id, node_id, app_id, app_name, domain, qtype, client_ip, client_region, carrier, sdk_version, os, result_ips, status, error_code, cost_ms, created_at, day, summary FROM %s", + s.tableName()) + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + query += " ORDER BY created_at DESC, request_id DESC" + query += fmt.Sprintf(" LIMIT %d OFFSET %d", size, offset) + + rawRows := []map[string]interface{}{} + if err := s.client.Query(ctx, query, &rawRows); err != nil { + return nil, err + } + + result := make([]*HTTPDNSAccessLogRow, 0, len(rawRows)) + for _, row := range rawRows { + result = append(result, &HTTPDNSAccessLogRow{ + RequestId: toString(row["request_id"]), + ClusterId: toInt64(row["cluster_id"]), + NodeId: toInt64(row["node_id"]), + AppId: toString(row["app_id"]), + AppName: toString(row["app_name"]), + Domain: toString(row["domain"]), + QType: toString(row["qtype"]), + ClientIP: toString(row["client_ip"]), + ClientRegion: toString(row["client_region"]), + Carrier: toString(row["carrier"]), + SDKVersion: toString(row["sdk_version"]), + OS: toString(row["os"]), + ResultIPs: toString(row["result_ips"]), + Status: toString(row["status"]), + ErrorCode: toString(row["error_code"]), + CostMs: int32(toInt64(row["cost_ms"])), + CreatedAt: toInt64(row["created_at"]), + Day: toString(row["day"]), + Summary: toString(row["summary"]), + }) + } + return result, nil +} + +func HTTPDNSRowToPB(row *HTTPDNSAccessLogRow) *pb.HTTPDNSAccessLog { + if row == nil { + return nil + } + return &pb.HTTPDNSAccessLog{ + RequestId: row.RequestId, + ClusterId: row.ClusterId, + NodeId: row.NodeId, + AppId: row.AppId, + AppName: row.AppName, + Domain: row.Domain, + Qtype: row.QType, + ClientIP: row.ClientIP, + ClientRegion: row.ClientRegion, + Carrier: row.Carrier, + SdkVersion: row.SDKVersion, + Os: row.OS, + ResultIPs: row.ResultIPs, + Status: row.Status, + ErrorCode: row.ErrorCode, + CostMs: row.CostMs, + CreatedAt: row.CreatedAt, + Day: row.Day, + Summary: row.Summary, + } +} + +func (s *HTTPDNSAccessLogsStore) buildConditions(f HTTPDNSAccessLogListFilter) []string { + conditions := []string{} + if day := strings.TrimSpace(f.Day); day != "" { + conditions = append(conditions, "day = '"+escapeString(day)+"'") + } + if f.ClusterId > 0 { + conditions = append(conditions, "cluster_id = "+strconv.FormatInt(f.ClusterId, 10)) + } + if f.NodeId > 0 { + conditions = append(conditions, "node_id = "+strconv.FormatInt(f.NodeId, 10)) + } + if appID := strings.TrimSpace(f.AppId); appID != "" { + conditions = append(conditions, "app_id = '"+escapeString(appID)+"'") + } else if len(f.AppIds) > 0 { + validAppIds := make([]string, 0, len(f.AppIds)) + for _, appID := range f.AppIds { + appID = strings.TrimSpace(appID) + if len(appID) == 0 { + continue + } + validAppIds = append(validAppIds, "'"+escapeString(appID)+"'") + } + if len(validAppIds) == 0 { + conditions = append(conditions, "1 = 0") + } else { + conditions = append(conditions, "app_id IN ("+strings.Join(validAppIds, ",")+")") + } + } + if domain := strings.TrimSpace(f.Domain); domain != "" { + conditions = append(conditions, "domain = '"+escapeString(domain)+"'") + } + if status := strings.TrimSpace(f.Status); status != "" { + conditions = append(conditions, "status = '"+escapeString(status)+"'") + } + if keyword := strings.TrimSpace(f.Keyword); keyword != "" { + kw := escapeString(keyword) + conditions = append(conditions, "(summary LIKE '%"+kw+"%' OR app_name LIKE '%"+kw+"%' OR client_ip LIKE '%"+kw+"%' OR result_ips LIKE '%"+kw+"%')") + } + return conditions +} + +func (s *HTTPDNSAccessLogsStore) tableName() string { + if s.client != nil && s.client.cfg != nil && s.client.cfg.Database != "" && s.client.cfg.Database != "default" { + return quoteIdent(s.client.cfg.Database) + "." + quoteIdent(httpDNSAccessLogsTable) + } + return quoteIdent(httpDNSAccessLogsTable) +} + +func toString(value interface{}) string { + if value == nil { + return "" + } + switch v := value.(type) { + case string: + return v + case json.Number: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} + +func toInt64(value interface{}) int64 { + if value == nil { + return 0 + } + switch v := value.(type) { + case int: + return int64(v) + case int32: + return int64(v) + case int64: + return v + case uint32: + return int64(v) + case uint64: + return int64(v) + case float64: + return int64(v) + case json.Number: + n, _ := v.Int64() + return n + case string: + n, _ := strconv.ParseInt(v, 10, 64) + return n + default: + return 0 + } +} diff --git a/EdgeAPI/internal/clickhouse/logs_ingest_store.go b/EdgeAPI/internal/clickhouse/logs_ingest_store.go index 4cbc965..84f7a56 100644 --- a/EdgeAPI/internal/clickhouse/logs_ingest_store.go +++ b/EdgeAPI/internal/clickhouse/logs_ingest_store.go @@ -351,8 +351,8 @@ func RowToPB(r *LogsIngestRow) *pb.HTTPAccessLog { RemoteAddr: r.IP, RequestMethod: r.Method, RequestPath: r.Path, - RequestURI: r.Path, // 前端使用 requestURI 显示完整路径 - Scheme: "http", // 默认 http,日志中未存储实际值 + RequestURI: r.Path, // 前端使用 requestURI 显示完整路径 + Scheme: "http", // 默认 http,日志中未存储实际值 Proto: "HTTP/1.1", // 默认值,日志中未存储实际值 Status: int32(r.Status), RequestLength: int64(r.BytesIn), diff --git a/EdgeAPI/internal/const/const.go b/EdgeAPI/internal/const/const.go index 89f1e42..c3b3d66 100644 --- a/EdgeAPI/internal/const/const.go +++ b/EdgeAPI/internal/const/const.go @@ -1,7 +1,7 @@ package teaconst const ( - Version = "1.4.7" //1.3.9 + Version = "1.4.9" //1.3.9 ProductName = "Edge API" ProcessName = "edge-api" @@ -17,6 +17,6 @@ const ( // 其他节点版本号,用来检测是否有需要升级的节点 - NodeVersion = "1.4.7" //1.3.8.2 + NodeVersion = "1.4.9" //1.3.8.2 ) diff --git a/EdgeAPI/internal/const/const_plus.go b/EdgeAPI/internal/const/const_plus.go index 44ea4f5..5b679bd 100644 --- a/EdgeAPI/internal/const/const_plus.go +++ b/EdgeAPI/internal/const/const_plus.go @@ -4,8 +4,8 @@ package teaconst const ( - DNSNodeVersion = "1.4.7" //1.3.8.2 - UserNodeVersion = "1.4.7" //1.3.8.2 + DNSNodeVersion = "1.4.9" //1.3.8.2 + UserNodeVersion = "1.4.9" //1.3.8.2 ReportNodeVersion = "0.1.5" DefaultMaxNodes int32 = 50 diff --git a/EdgeAPI/internal/db/models/httpdns_access_log_dao.go b/EdgeAPI/internal/db/models/httpdns_access_log_dao.go new file mode 100644 index 0000000..8dc4b01 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_access_log_dao.go @@ -0,0 +1,134 @@ +package models + +import ( + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" + "strings" +) + +type HTTPDNSAccessLogDAO dbs.DAO + +func NewHTTPDNSAccessLogDAO() *HTTPDNSAccessLogDAO { + return dbs.NewDAO(&HTTPDNSAccessLogDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeHTTPDNSAccessLogs", + Model: new(HTTPDNSAccessLog), + PkName: "id", + }, + }).(*HTTPDNSAccessLogDAO) +} + +var SharedHTTPDNSAccessLogDAO *HTTPDNSAccessLogDAO + +func init() { + dbs.OnReady(func() { + SharedHTTPDNSAccessLogDAO = NewHTTPDNSAccessLogDAO() + }) +} + +func (this *HTTPDNSAccessLogDAO) CreateLog(tx *dbs.Tx, log *HTTPDNSAccessLog) error { + var op = NewHTTPDNSAccessLogOperator() + op.RequestId = log.RequestId + op.ClusterId = log.ClusterId + op.NodeId = log.NodeId + op.AppId = log.AppId + op.AppName = log.AppName + op.Domain = log.Domain + op.QType = log.QType + op.ClientIP = log.ClientIP + op.ClientRegion = log.ClientRegion + op.Carrier = log.Carrier + op.SDKVersion = log.SDKVersion + op.OS = log.OS + op.ResultIPs = log.ResultIPs + op.Status = log.Status + op.ErrorCode = log.ErrorCode + op.CostMs = log.CostMs + op.CreatedAt = log.CreatedAt + op.Day = log.Day + op.Summary = log.Summary + return this.Save(tx, op) +} + +func (this *HTTPDNSAccessLogDAO) BuildListQuery(tx *dbs.Tx, day string, clusterId int64, nodeId int64, appId string, domain string, status string, keyword string) *dbs.Query { + return this.BuildListQueryWithAppIds(tx, day, clusterId, nodeId, appId, nil, domain, status, keyword) +} + +func (this *HTTPDNSAccessLogDAO) BuildListQueryWithAppIds(tx *dbs.Tx, day string, clusterId int64, nodeId int64, appId string, appIds []string, domain string, status string, keyword string) *dbs.Query { + query := this.Query(tx).DescPk() + if len(day) > 0 { + query = query.Attr("day", day) + } + if clusterId > 0 { + query = query.Attr("clusterId", clusterId) + } + if nodeId > 0 { + query = query.Attr("nodeId", nodeId) + } + if len(appIds) > 0 { + validAppIds := make([]string, 0, len(appIds)) + for _, value := range appIds { + value = strings.TrimSpace(value) + if len(value) == 0 { + continue + } + validAppIds = append(validAppIds, value) + } + if len(validAppIds) == 0 { + query = query.Where("1 = 0") + } else { + query = query.Attr("appId", validAppIds) + } + } + if len(appId) > 0 { + query = query.Attr("appId", appId) + } + if len(domain) > 0 { + query = query.Attr("domain", domain) + } + if len(status) > 0 { + query = query.Attr("status", status) + } + if len(keyword) > 0 { + query = query.Where("(summary LIKE :kw OR appName LIKE :kw OR clientIP LIKE :kw OR resultIPs LIKE :kw)").Param("kw", "%"+keyword+"%") + } + return query +} + +func (this *HTTPDNSAccessLogDAO) CountLogs(tx *dbs.Tx, day string, clusterId int64, nodeId int64, appId string, domain string, status string, keyword string) (int64, error) { + return this.BuildListQueryWithAppIds(tx, day, clusterId, nodeId, appId, nil, domain, status, keyword).Count() +} + +func (this *HTTPDNSAccessLogDAO) ListLogs(tx *dbs.Tx, day string, clusterId int64, nodeId int64, appId string, domain string, status string, keyword string, offset int64, size int64) (result []*HTTPDNSAccessLog, err error) { + _, err = this.BuildListQueryWithAppIds(tx, day, clusterId, nodeId, appId, nil, domain, status, keyword). + Offset(offset). + Limit(size). + Slice(&result). + FindAll() + return +} + +func (this *HTTPDNSAccessLogDAO) CountLogsWithAppIds(tx *dbs.Tx, day string, clusterId int64, nodeId int64, appId string, appIds []string, domain string, status string, keyword string) (int64, error) { + return this.BuildListQueryWithAppIds(tx, day, clusterId, nodeId, appId, appIds, domain, status, keyword).Count() +} + +func (this *HTTPDNSAccessLogDAO) ListLogsWithAppIds(tx *dbs.Tx, day string, clusterId int64, nodeId int64, appId string, appIds []string, domain string, status string, keyword string, offset int64, size int64) (result []*HTTPDNSAccessLog, err error) { + _, err = this.BuildListQueryWithAppIds(tx, day, clusterId, nodeId, appId, appIds, domain, status, keyword). + Offset(offset). + Limit(size). + Slice(&result). + FindAll() + return +} + +func (this *HTTPDNSAccessLogDAO) DeleteLogsWithAppId(tx *dbs.Tx, appId string) error { + if len(appId) == 0 { + return nil + } + _, err := this.Query(tx). + Attr("appId", appId). + Delete() + return err +} diff --git a/EdgeAPI/internal/db/models/httpdns_access_log_model.go b/EdgeAPI/internal/db/models/httpdns_access_log_model.go new file mode 100644 index 0000000..0c8da61 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_access_log_model.go @@ -0,0 +1,52 @@ +package models + +// HTTPDNSAccessLog 访问日志 +type HTTPDNSAccessLog struct { + Id uint64 `field:"id"` // ID + RequestId string `field:"requestId"` // 请求ID + ClusterId uint32 `field:"clusterId"` // 集群ID + NodeId uint32 `field:"nodeId"` // 节点ID + AppId string `field:"appId"` // AppID + AppName string `field:"appName"` // 应用名 + Domain string `field:"domain"` // 域名 + QType string `field:"qtype"` // 查询类型 + ClientIP string `field:"clientIP"` // 客户端IP + ClientRegion string `field:"clientRegion"` // 客户端区域 + Carrier string `field:"carrier"` // 运营商 + SDKVersion string `field:"sdkVersion"` // SDK版本 + OS string `field:"os"` // 系统 + ResultIPs string `field:"resultIPs"` // 结果IP + Status string `field:"status"` // 状态 + ErrorCode string `field:"errorCode"` // 错误码 + CostMs int32 `field:"costMs"` // 耗时 + CreatedAt uint64 `field:"createdAt"` // 创建时间 + Day string `field:"day"` // YYYYMMDD + Summary string `field:"summary"` // 概要 +} + +type HTTPDNSAccessLogOperator struct { + Id any // ID + RequestId any // 请求ID + ClusterId any // 集群ID + NodeId any // 节点ID + AppId any // AppID + AppName any // 应用名 + Domain any // 域名 + QType any // 查询类型 + ClientIP any // 客户端IP + ClientRegion any // 客户端区域 + Carrier any // 运营商 + SDKVersion any // SDK版本 + OS any // 系统 + ResultIPs any // 结果IP + Status any // 状态 + ErrorCode any // 错误码 + CostMs any // 耗时 + CreatedAt any // 创建时间 + Day any // YYYYMMDD + Summary any // 概要 +} + +func NewHTTPDNSAccessLogOperator() *HTTPDNSAccessLogOperator { + return &HTTPDNSAccessLogOperator{} +} diff --git a/EdgeAPI/internal/db/models/httpdns_app_dao.go b/EdgeAPI/internal/db/models/httpdns_app_dao.go new file mode 100644 index 0000000..bbcd9e0 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_app_dao.go @@ -0,0 +1,239 @@ +package models + +import ( + "encoding/json" + + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" + "github.com/iwind/TeaGo/types" + "time" +) + +const ( + HTTPDNSAppStateEnabled = 1 + HTTPDNSAppStateDisabled = 0 + HTTPDNSSNIModeFixedHide = "fixed_hide" +) + +type HTTPDNSAppDAO dbs.DAO + +func NewHTTPDNSAppDAO() *HTTPDNSAppDAO { + return dbs.NewDAO(&HTTPDNSAppDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeHTTPDNSApps", + Model: new(HTTPDNSApp), + PkName: "id", + }, + }).(*HTTPDNSAppDAO) +} + +var SharedHTTPDNSAppDAO *HTTPDNSAppDAO + +func init() { + dbs.OnReady(func() { + SharedHTTPDNSAppDAO = NewHTTPDNSAppDAO() + }) +} + +func (this *HTTPDNSAppDAO) CreateApp(tx *dbs.Tx, name string, appId string, clusterIdsJSON []byte, isOn bool, userId int64) (int64, error) { + var op = NewHTTPDNSAppOperator() + op.Name = name + op.AppId = appId + + if len(clusterIdsJSON) > 0 { + op.ClusterIdsJSON = string(clusterIdsJSON) + } else { + op.ClusterIdsJSON = "[]" + } + + op.IsOn = isOn + op.UserId = userId + op.SNIMode = HTTPDNSSNIModeFixedHide + op.CreatedAt = time.Now().Unix() + op.UpdatedAt = time.Now().Unix() + op.State = HTTPDNSAppStateEnabled + err := this.Save(tx, op) + if err != nil { + return 0, err + } + return types.Int64(op.Id), nil +} + +func (this *HTTPDNSAppDAO) UpdateApp(tx *dbs.Tx, appDbId int64, name string, clusterIdsJSON []byte, isOn bool, userId int64) error { + var op = NewHTTPDNSAppOperator() + op.Id = appDbId + op.Name = name + + if len(clusterIdsJSON) > 0 { + op.ClusterIdsJSON = string(clusterIdsJSON) + } else { + op.ClusterIdsJSON = "[]" + } + + op.IsOn = isOn + op.UserId = userId + op.UpdatedAt = time.Now().Unix() + return this.Save(tx, op) +} + +// ReadAppClusterIds reads cluster IDs from ClusterIdsJSON. +func (this *HTTPDNSAppDAO) ReadAppClusterIds(app *HTTPDNSApp) []int64 { + if app == nil { + return nil + } + if len(app.ClusterIdsJSON) > 0 { + var ids []int64 + if err := json.Unmarshal([]byte(app.ClusterIdsJSON), &ids); err == nil && len(ids) > 0 { + return ids + } + } + return nil +} + +func (this *HTTPDNSAppDAO) DisableApp(tx *dbs.Tx, appDbId int64) error { + _, err := this.Query(tx). + Pk(appDbId). + Set("state", HTTPDNSAppStateDisabled). + Update() + return err +} + +func (this *HTTPDNSAppDAO) FindEnabledApp(tx *dbs.Tx, appDbId int64) (*HTTPDNSApp, error) { + one, err := this.Query(tx). + Pk(appDbId). + State(HTTPDNSAppStateEnabled). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSApp), nil +} + +func (this *HTTPDNSAppDAO) FindEnabledAppWithUser(tx *dbs.Tx, appDbId int64, userId int64) (*HTTPDNSApp, error) { + one, err := this.Query(tx). + Pk(appDbId). + State(HTTPDNSAppStateEnabled). + Attr("userId", userId). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSApp), nil +} + +func (this *HTTPDNSAppDAO) FindEnabledAppWithAppId(tx *dbs.Tx, appId string) (*HTTPDNSApp, error) { + one, err := this.Query(tx). + State(HTTPDNSAppStateEnabled). + Attr("appId", appId). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSApp), nil +} + +func (this *HTTPDNSAppDAO) FindEnabledAppWithAppIdAndUser(tx *dbs.Tx, appId string, userId int64) (*HTTPDNSApp, error) { + one, err := this.Query(tx). + State(HTTPDNSAppStateEnabled). + Attr("appId", appId). + Attr("userId", userId). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSApp), nil +} + +func (this *HTTPDNSAppDAO) FindLatestEnabledAppWithNameAndUser(tx *dbs.Tx, name string, userId int64) (*HTTPDNSApp, error) { + one, err := this.Query(tx). + State(HTTPDNSAppStateEnabled). + Attr("name", name). + Attr("userId", userId). + DescPk(). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSApp), nil +} + +func (this *HTTPDNSAppDAO) ListEnabledApps(tx *dbs.Tx, offset int64, size int64, keyword string) (result []*HTTPDNSApp, err error) { + query := this.Query(tx). + State(HTTPDNSAppStateEnabled). + AscPk() + if len(keyword) > 0 { + query = query.Where("(name LIKE :kw OR appId LIKE :kw)").Param("kw", "%"+keyword+"%") + } + if size > 0 { + query = query.Offset(offset).Limit(size) + } + _, err = query.Slice(&result).FindAll() + return +} + +func (this *HTTPDNSAppDAO) ListEnabledAppsWithUser(tx *dbs.Tx, userId int64, offset int64, size int64, keyword string) (result []*HTTPDNSApp, err error) { + query := this.Query(tx). + State(HTTPDNSAppStateEnabled). + Attr("userId", userId). + AscPk() + if len(keyword) > 0 { + query = query.Where("(name LIKE :kw OR appId LIKE :kw)").Param("kw", "%"+keyword+"%") + } + if size > 0 { + query = query.Offset(offset).Limit(size) + } + _, err = query.Slice(&result).FindAll() + return +} + +func (this *HTTPDNSAppDAO) CountEnabledApps(tx *dbs.Tx, keyword string) (int64, error) { + query := this.Query(tx).State(HTTPDNSAppStateEnabled) + if len(keyword) > 0 { + query = query.Where("(name LIKE :kw OR appId LIKE :kw)").Param("kw", "%"+keyword+"%") + } + return query.Count() +} + +func (this *HTTPDNSAppDAO) CountEnabledAppsWithUser(tx *dbs.Tx, userId int64, keyword string) (int64, error) { + query := this.Query(tx).State(HTTPDNSAppStateEnabled).Attr("userId", userId) + if len(keyword) > 0 { + query = query.Where("(name LIKE :kw OR appId LIKE :kw)").Param("kw", "%"+keyword+"%") + } + return query.Count() +} + +func (this *HTTPDNSAppDAO) FindAllEnabledApps(tx *dbs.Tx) (result []*HTTPDNSApp, err error) { + _, err = this.Query(tx). + State(HTTPDNSAppStateEnabled). + AscPk(). + Slice(&result). + FindAll() + return +} + +func (this *HTTPDNSAppDAO) FindAllEnabledAppsWithUser(tx *dbs.Tx, userId int64) (result []*HTTPDNSApp, err error) { + _, err = this.Query(tx). + State(HTTPDNSAppStateEnabled). + Attr("userId", userId). + AscPk(). + Slice(&result). + FindAll() + return +} + +func (this *HTTPDNSAppDAO) ListEnabledAppIdsWithUser(tx *dbs.Tx, userId int64) (result []string, err error) { + apps, err := this.FindAllEnabledAppsWithUser(tx, userId) + if err != nil { + return nil, err + } + result = make([]string, 0, len(apps)) + for _, app := range apps { + if app == nil || len(app.AppId) == 0 { + continue + } + result = append(result, app.AppId) + } + return +} diff --git a/EdgeAPI/internal/db/models/httpdns_app_model.go b/EdgeAPI/internal/db/models/httpdns_app_model.go new file mode 100644 index 0000000..58d5b29 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_app_model.go @@ -0,0 +1,34 @@ +package models + +// HTTPDNSApp maps to edgeHTTPDNSApps. +type HTTPDNSApp struct { + Id uint32 `field:"id"` // id + Name string `field:"name"` // app name + AppId string `field:"appId"` // external app id + IsOn bool `field:"isOn"` // enabled + ClusterIdsJSON string `field:"clusterIdsJSON"` // cluster ids json + SNIMode string `field:"sniMode"` // sni mode + UserId int64 `field:"userId"` // owner user id + CreatedAt uint64 `field:"createdAt"` // created unix ts + UpdatedAt uint64 `field:"updatedAt"` // updated unix ts + State uint8 `field:"state"` // state +} + +// HTTPDNSAppOperator is used by DAO save/update. +type HTTPDNSAppOperator struct { + Id any // id + Name any // app name + AppId any // external app id + IsOn any // enabled + ClusterIdsJSON any // cluster ids json + SNIMode any // sni mode + UserId any // owner user id + CreatedAt any // created unix ts + UpdatedAt any // updated unix ts + State any // state +} + +func NewHTTPDNSAppOperator() *HTTPDNSAppOperator { + return &HTTPDNSAppOperator{} +} + diff --git a/EdgeAPI/internal/db/models/httpdns_app_secret_dao.go b/EdgeAPI/internal/db/models/httpdns_app_secret_dao.go new file mode 100644 index 0000000..37ca9f0 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_app_secret_dao.go @@ -0,0 +1,146 @@ +package models + +import ( + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" + "github.com/iwind/TeaGo/rands" + "github.com/iwind/TeaGo/types" + "time" +) + +const ( + HTTPDNSAppSecretStateEnabled = 1 + HTTPDNSAppSecretStateDisabled = 0 +) + +type HTTPDNSAppSecretDAO dbs.DAO + +func NewHTTPDNSAppSecretDAO() *HTTPDNSAppSecretDAO { + return dbs.NewDAO(&HTTPDNSAppSecretDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeHTTPDNSAppSecrets", + Model: new(HTTPDNSAppSecret), + PkName: "id", + }, + }).(*HTTPDNSAppSecretDAO) +} + +var SharedHTTPDNSAppSecretDAO *HTTPDNSAppSecretDAO + +func init() { + dbs.OnReady(func() { + SharedHTTPDNSAppSecretDAO = NewHTTPDNSAppSecretDAO() + }) +} + +func (this *HTTPDNSAppSecretDAO) InitAppSecret(tx *dbs.Tx, appDbId int64, signEnabled bool) (string, uint64, error) { + signSecret := "ss_" + rands.HexString(12) + now := uint64(time.Now().Unix()) + + // 兼容历史数据:如果已存在(可能是停用状态)则直接恢复并更新,避免 UNIQUE(appId) 冲突 + old, err := this.Query(tx). + Attr("appId", appDbId). + Find() + if err != nil { + return "", 0, err + } + if old != nil { + oldSecret := old.(*HTTPDNSAppSecret) + _, err = this.Query(tx). + Pk(oldSecret.Id). + Set("signEnabled", signEnabled). + Set("signSecret", signSecret). + Set("signUpdatedAt", now). + Set("updatedAt", now). + Set("state", HTTPDNSAppSecretStateEnabled). + Update() + return signSecret, now, err + } + + var op = NewHTTPDNSAppSecretOperator() + op.AppId = appDbId + op.SignEnabled = signEnabled + op.SignSecret = signSecret + op.SignUpdatedAt = now + op.UpdatedAt = now + op.State = HTTPDNSAppSecretStateEnabled + err = this.Save(tx, op) + return signSecret, now, err +} + +func (this *HTTPDNSAppSecretDAO) FindEnabledAppSecret(tx *dbs.Tx, appDbId int64) (*HTTPDNSAppSecret, error) { + one, err := this.Query(tx). + Attr("appId", appDbId). + State(HTTPDNSAppSecretStateEnabled). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSAppSecret), nil +} + +func (this *HTTPDNSAppSecretDAO) UpdateSignEnabled(tx *dbs.Tx, appDbId int64, signEnabled bool) error { + _, err := this.Query(tx). + Attr("appId", appDbId). + State(HTTPDNSAppSecretStateEnabled). + Set("signEnabled", signEnabled). + Set("updatedAt", time.Now().Unix()). + Update() + return err +} + +func (this *HTTPDNSAppSecretDAO) ResetSignSecret(tx *dbs.Tx, appDbId int64) (string, int64, error) { + signSecret := "ss_" + rands.HexString(12) + now := time.Now().Unix() + _, err := this.Query(tx). + Attr("appId", appDbId). + State(HTTPDNSAppSecretStateEnabled). + Set("signSecret", signSecret). + Set("signUpdatedAt", now). + Set("updatedAt", now). + Update() + if err != nil { + return "", 0, err + } + return signSecret, now, nil +} + +func (this *HTTPDNSAppSecretDAO) FindSignEnabled(tx *dbs.Tx, appDbId int64) (bool, error) { + one, err := this.FindEnabledAppSecret(tx, appDbId) + if err != nil || one == nil { + return false, err + } + return one.SignEnabled, nil +} + +func (this *HTTPDNSAppSecretDAO) FindSignSecretWithAppDbId(tx *dbs.Tx, appDbId int64) (string, error) { + return this.Query(tx). + Attr("appId", appDbId). + State(HTTPDNSAppSecretStateEnabled). + Result("signSecret"). + FindStringCol("") +} + +func (this *HTTPDNSAppSecretDAO) FindSignUpdatedAt(tx *dbs.Tx, appDbId int64) (int64, error) { + col, err := this.Query(tx). + Attr("appId", appDbId). + State(HTTPDNSAppSecretStateEnabled). + Result("signUpdatedAt"). + FindCol(nil) + if err != nil { + return 0, err + } + return types.Int64(col), nil +} + +func (this *HTTPDNSAppSecretDAO) DisableAppSecret(tx *dbs.Tx, appDbId int64) error { + _, err := this.Query(tx). + Attr("appId", appDbId). + State(HTTPDNSAppSecretStateEnabled). + Set("state", HTTPDNSAppSecretStateDisabled). + Set("updatedAt", time.Now().Unix()). + Update() + return err +} diff --git a/EdgeAPI/internal/db/models/httpdns_app_secret_model.go b/EdgeAPI/internal/db/models/httpdns_app_secret_model.go new file mode 100644 index 0000000..92563a7 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_app_secret_model.go @@ -0,0 +1,26 @@ +package models + +// HTTPDNSAppSecret 应用验签密钥配置 +type HTTPDNSAppSecret struct { + Id uint32 `field:"id"` // ID + AppId uint32 `field:"appId"` // 应用DB ID + SignEnabled bool `field:"signEnabled"` // 是否启用验签 + SignSecret string `field:"signSecret"` // 验签密钥(当前先明文存储) + SignUpdatedAt uint64 `field:"signUpdatedAt"` // 验签密钥更新时间 + UpdatedAt uint64 `field:"updatedAt"` // 修改时间 + State uint8 `field:"state"` // 记录状态 +} + +type HTTPDNSAppSecretOperator struct { + Id any // ID + AppId any // 应用DB ID + SignEnabled any // 是否启用验签 + SignSecret any // 验签密钥 + SignUpdatedAt any // 验签密钥更新时间 + UpdatedAt any // 修改时间 + State any // 记录状态 +} + +func NewHTTPDNSAppSecretOperator() *HTTPDNSAppSecretOperator { + return &HTTPDNSAppSecretOperator{} +} diff --git a/EdgeAPI/internal/db/models/httpdns_cluster_dao.go b/EdgeAPI/internal/db/models/httpdns_cluster_dao.go new file mode 100644 index 0000000..9a5af68 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_cluster_dao.go @@ -0,0 +1,191 @@ +package models + +import ( + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" + "github.com/iwind/TeaGo/types" + "time" +) + +const ( + HTTPDNSClusterStateEnabled = 1 + HTTPDNSClusterStateDisabled = 0 +) + +type HTTPDNSClusterDAO dbs.DAO + +func NewHTTPDNSClusterDAO() *HTTPDNSClusterDAO { + return dbs.NewDAO(&HTTPDNSClusterDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeHTTPDNSClusters", + Model: new(HTTPDNSCluster), + PkName: "id", + }, + }).(*HTTPDNSClusterDAO) +} + +var SharedHTTPDNSClusterDAO *HTTPDNSClusterDAO + +func init() { + dbs.OnReady(func() { + SharedHTTPDNSClusterDAO = NewHTTPDNSClusterDAO() + }) +} + +func (this *HTTPDNSClusterDAO) CreateCluster(tx *dbs.Tx, name string, serviceDomain string, defaultTTL int32, fallbackTimeoutMs int32, installDir string, tlsPolicyJSON []byte, isOn bool, isDefault bool, autoRemoteStart bool, accessLogIsOn bool, timeZone string) (int64, error) { + if isDefault { + err := this.Query(tx). + State(HTTPDNSClusterStateEnabled). + Set("isDefault", false). + UpdateQuickly() + if err != nil { + return 0, err + } + } + + var op = NewHTTPDNSClusterOperator() + op.Name = name + op.ServiceDomain = serviceDomain + op.DefaultTTL = defaultTTL + op.FallbackTimeoutMs = fallbackTimeoutMs + op.InstallDir = installDir + op.IsOn = isOn + op.IsDefault = isDefault + op.AutoRemoteStart = autoRemoteStart + op.AccessLogIsOn = accessLogIsOn + op.TimeZone = timeZone + op.CreatedAt = time.Now().Unix() + op.UpdatedAt = time.Now().Unix() + op.State = HTTPDNSClusterStateEnabled + if len(tlsPolicyJSON) > 0 { + op.TLSPolicy = tlsPolicyJSON + } + err := this.Save(tx, op) + if err != nil { + return 0, err + } + return types.Int64(op.Id), nil +} + +func (this *HTTPDNSClusterDAO) UpdateCluster(tx *dbs.Tx, clusterId int64, name string, serviceDomain string, defaultTTL int32, fallbackTimeoutMs int32, installDir string, tlsPolicyJSON []byte, isOn bool, isDefault bool, autoRemoteStart bool, accessLogIsOn bool, timeZone string) error { + if isDefault { + err := this.Query(tx). + State(HTTPDNSClusterStateEnabled). + Neq("id", clusterId). + Set("isDefault", false). + UpdateQuickly() + if err != nil { + return err + } + } + + var op = NewHTTPDNSClusterOperator() + op.Id = clusterId + op.Name = name + op.ServiceDomain = serviceDomain + op.DefaultTTL = defaultTTL + op.FallbackTimeoutMs = fallbackTimeoutMs + op.InstallDir = installDir + op.IsOn = isOn + op.IsDefault = isDefault + op.AutoRemoteStart = autoRemoteStart + op.AccessLogIsOn = accessLogIsOn + op.TimeZone = timeZone + op.UpdatedAt = time.Now().Unix() + if len(tlsPolicyJSON) > 0 { + op.TLSPolicy = tlsPolicyJSON + } + return this.Save(tx, op) +} + +func (this *HTTPDNSClusterDAO) DisableCluster(tx *dbs.Tx, clusterId int64) error { + _, err := this.Query(tx). + Pk(clusterId). + Set("state", HTTPDNSClusterStateDisabled). + Update() + return err +} + +func (this *HTTPDNSClusterDAO) FindEnabledCluster(tx *dbs.Tx, clusterId int64) (*HTTPDNSCluster, error) { + one, err := this.Query(tx). + Pk(clusterId). + State(HTTPDNSClusterStateEnabled). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSCluster), nil +} + +func (this *HTTPDNSClusterDAO) FindEnabledClusterName(tx *dbs.Tx, clusterId int64) (string, error) { + return this.Query(tx). + Pk(clusterId). + State(HTTPDNSClusterStateEnabled). + Result("name"). + FindStringCol("") +} + +func (this *HTTPDNSClusterDAO) ListEnabledClusters(tx *dbs.Tx, offset int64, size int64, keyword string) (result []*HTTPDNSCluster, err error) { + query := this.Query(tx). + State(HTTPDNSClusterStateEnabled). + AscPk() + if len(keyword) > 0 { + query = query.Where("(name LIKE :kw OR serviceDomain LIKE :kw)").Param("kw", "%"+keyword+"%") + } + if size > 0 { + query = query.Offset(offset).Limit(size) + } + _, err = query.Slice(&result).FindAll() + return +} + +func (this *HTTPDNSClusterDAO) CountEnabledClusters(tx *dbs.Tx, keyword string) (int64, error) { + query := this.Query(tx).State(HTTPDNSClusterStateEnabled) + if len(keyword) > 0 { + query = query.Where("(name LIKE :kw OR serviceDomain LIKE :kw)").Param("kw", "%"+keyword+"%") + } + return query.Count() +} + +func (this *HTTPDNSClusterDAO) FindAllEnabledClusters(tx *dbs.Tx) (result []*HTTPDNSCluster, err error) { + _, err = this.Query(tx). + State(HTTPDNSClusterStateEnabled). + AscPk(). + Slice(&result). + FindAll() + return +} + +func (this *HTTPDNSClusterDAO) FindDefaultPrimaryClusterId(tx *dbs.Tx) (int64, error) { + col, err := this.Query(tx). + State(HTTPDNSClusterStateEnabled). + Attr("isDefault", true). + Result("id"). + AscPk(). + FindCol(nil) + if err != nil { + return 0, err + } + if col == nil { + return 0, nil + } + return types.Int64(col), nil +} + +func (this *HTTPDNSClusterDAO) UpdateDefaultCluster(tx *dbs.Tx, clusterId int64) error { + err := this.Query(tx). + State(HTTPDNSClusterStateEnabled). + Set("isDefault", false). + UpdateQuickly() + if err != nil { + return err + } + _, err = this.Query(tx). + Pk(clusterId). + State(HTTPDNSClusterStateEnabled). + Set("isDefault", true). + Update() + return err +} diff --git a/EdgeAPI/internal/db/models/httpdns_cluster_model.go b/EdgeAPI/internal/db/models/httpdns_cluster_model.go new file mode 100644 index 0000000..917c562 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_cluster_model.go @@ -0,0 +1,44 @@ +package models + +import "github.com/iwind/TeaGo/dbs" + +// HTTPDNSCluster HTTPDNS集群 +type HTTPDNSCluster struct { + Id uint32 `field:"id"` // ID + Name string `field:"name"` // 集群名称 + IsOn bool `field:"isOn"` // 是否启用 + IsDefault bool `field:"isDefault"` // 默认集群 + ServiceDomain string `field:"serviceDomain"` // 服务域名 + DefaultTTL int32 `field:"defaultTTL"` // 默认TTL + FallbackTimeoutMs int32 `field:"fallbackTimeoutMs"` // 降级超时 + InstallDir string `field:"installDir"` // 安装目录 + TLSPolicy dbs.JSON `field:"tlsPolicy"` // TLS策略 + AutoRemoteStart bool `field:"autoRemoteStart"` // 自动远程启动 + AccessLogIsOn bool `field:"accessLogIsOn"` // 访问日志是否开启 + TimeZone string `field:"timeZone"` // 时区 + CreatedAt uint64 `field:"createdAt"` // 创建时间 + UpdatedAt uint64 `field:"updatedAt"` // 修改时间 + State uint8 `field:"state"` // 记录状态 +} + +type HTTPDNSClusterOperator struct { + Id any // ID + Name any // 集群名称 + IsOn any // 是否启用 + IsDefault any // 默认集群 + ServiceDomain any // 服务域名 + DefaultTTL any // 默认TTL + FallbackTimeoutMs any // 降级超时 + InstallDir any // 安装目录 + TLSPolicy any // TLS策略 + AutoRemoteStart any // 自动远程启动 + AccessLogIsOn any // 访问日志是否开启 + TimeZone any // 时区 + CreatedAt any // 创建时间 + UpdatedAt any // 修改时间 + State any // 记录状态 +} + +func NewHTTPDNSClusterOperator() *HTTPDNSClusterOperator { + return &HTTPDNSClusterOperator{} +} diff --git a/EdgeAPI/internal/db/models/httpdns_custom_rule_dao.go b/EdgeAPI/internal/db/models/httpdns_custom_rule_dao.go new file mode 100644 index 0000000..0df5629 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_custom_rule_dao.go @@ -0,0 +1,143 @@ +package models + +import ( + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" + "github.com/iwind/TeaGo/types" + "time" +) + +const ( + HTTPDNSCustomRuleStateEnabled = 1 + HTTPDNSCustomRuleStateDisabled = 0 +) + +type HTTPDNSCustomRuleDAO dbs.DAO + +func NewHTTPDNSCustomRuleDAO() *HTTPDNSCustomRuleDAO { + return dbs.NewDAO(&HTTPDNSCustomRuleDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeHTTPDNSCustomRules", + Model: new(HTTPDNSCustomRule), + PkName: "id", + }, + }).(*HTTPDNSCustomRuleDAO) +} + +var SharedHTTPDNSCustomRuleDAO *HTTPDNSCustomRuleDAO + +func init() { + dbs.OnReady(func() { + SharedHTTPDNSCustomRuleDAO = NewHTTPDNSCustomRuleDAO() + }) +} + +func (this *HTTPDNSCustomRuleDAO) CreateRule(tx *dbs.Tx, rule *HTTPDNSCustomRule) (int64, error) { + var op = NewHTTPDNSCustomRuleOperator() + op.AppId = rule.AppId + op.DomainId = rule.DomainId + op.RuleName = rule.RuleName + op.LineScope = rule.LineScope + op.LineCarrier = rule.LineCarrier + op.LineRegion = rule.LineRegion + op.LineProvince = rule.LineProvince + op.LineContinent = rule.LineContinent + op.LineCountry = rule.LineCountry + op.TTL = rule.TTL + op.IsOn = rule.IsOn + op.Priority = rule.Priority + op.UpdatedAt = time.Now().Unix() + op.State = HTTPDNSCustomRuleStateEnabled + err := this.Save(tx, op) + if err != nil { + return 0, err + } + return types.Int64(op.Id), nil +} + +func (this *HTTPDNSCustomRuleDAO) UpdateRule(tx *dbs.Tx, rule *HTTPDNSCustomRule) error { + var op = NewHTTPDNSCustomRuleOperator() + op.Id = rule.Id + op.RuleName = rule.RuleName + op.LineScope = rule.LineScope + op.LineCarrier = rule.LineCarrier + op.LineRegion = rule.LineRegion + op.LineProvince = rule.LineProvince + op.LineContinent = rule.LineContinent + op.LineCountry = rule.LineCountry + op.TTL = rule.TTL + op.IsOn = rule.IsOn + op.Priority = rule.Priority + op.UpdatedAt = time.Now().Unix() + return this.Save(tx, op) +} + +func (this *HTTPDNSCustomRuleDAO) DisableRule(tx *dbs.Tx, ruleId int64) error { + _, err := this.Query(tx). + Pk(ruleId). + Set("state", HTTPDNSCustomRuleStateDisabled). + Update() + return err +} + +func (this *HTTPDNSCustomRuleDAO) DisableRulesWithAppId(tx *dbs.Tx, appDbId int64) error { + _, err := this.Query(tx). + Attr("appId", appDbId). + State(HTTPDNSCustomRuleStateEnabled). + Set("state", HTTPDNSCustomRuleStateDisabled). + Set("updatedAt", time.Now().Unix()). + Update() + return err +} + +func (this *HTTPDNSCustomRuleDAO) UpdateRuleStatus(tx *dbs.Tx, ruleId int64, isOn bool) error { + _, err := this.Query(tx). + Pk(ruleId). + State(HTTPDNSCustomRuleStateEnabled). + Set("isOn", isOn). + Set("updatedAt", time.Now().Unix()). + Update() + return err +} + +func (this *HTTPDNSCustomRuleDAO) FindEnabledRule(tx *dbs.Tx, ruleId int64) (*HTTPDNSCustomRule, error) { + one, err := this.Query(tx). + Pk(ruleId). + State(HTTPDNSCustomRuleStateEnabled). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSCustomRule), nil +} + +func (this *HTTPDNSCustomRuleDAO) ListEnabledRulesWithDomainId(tx *dbs.Tx, domainId int64) (result []*HTTPDNSCustomRule, err error) { + _, err = this.Query(tx). + State(HTTPDNSCustomRuleStateEnabled). + Attr("domainId", domainId). + Asc("priority"). + AscPk(). + Slice(&result). + FindAll() + return +} + +func (this *HTTPDNSCustomRuleDAO) ListEnabledRulesWithAppId(tx *dbs.Tx, appDbId int64) (result []*HTTPDNSCustomRule, err error) { + _, err = this.Query(tx). + State(HTTPDNSCustomRuleStateEnabled). + Attr("appId", appDbId). + Asc("priority"). + AscPk(). + Slice(&result). + FindAll() + return +} + +func (this *HTTPDNSCustomRuleDAO) CountEnabledRulesWithDomainId(tx *dbs.Tx, domainId int64) (int64, error) { + return this.Query(tx). + State(HTTPDNSCustomRuleStateEnabled). + Attr("domainId", domainId). + Count() +} diff --git a/EdgeAPI/internal/db/models/httpdns_custom_rule_model.go b/EdgeAPI/internal/db/models/httpdns_custom_rule_model.go new file mode 100644 index 0000000..8120152 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_custom_rule_model.go @@ -0,0 +1,42 @@ +package models + +// HTTPDNSCustomRule 自定义解析规则 +type HTTPDNSCustomRule struct { + Id uint32 `field:"id"` // ID + AppId uint32 `field:"appId"` // 应用DB ID + DomainId uint32 `field:"domainId"` // 域名ID + RuleName string `field:"ruleName"` // 规则名称 + LineScope string `field:"lineScope"` // 线路范围 + LineCarrier string `field:"lineCarrier"` // 运营商 + LineRegion string `field:"lineRegion"` // 区域 + LineProvince string `field:"lineProvince"` // 省份 + LineContinent string `field:"lineContinent"` // 大洲 + LineCountry string `field:"lineCountry"` // 国家 + TTL int32 `field:"ttl"` // TTL + IsOn bool `field:"isOn"` // 启用状态 + Priority int32 `field:"priority"` // 优先级 + UpdatedAt uint64 `field:"updatedAt"` // 修改时间 + State uint8 `field:"state"` // 记录状态 +} + +type HTTPDNSCustomRuleOperator struct { + Id any // ID + AppId any // 应用DB ID + DomainId any // 域名ID + RuleName any // 规则名称 + LineScope any // 线路范围 + LineCarrier any // 运营商 + LineRegion any // 区域 + LineProvince any // 省份 + LineContinent any // 大洲 + LineCountry any // 国家 + TTL any // TTL + IsOn any // 启用状态 + Priority any // 优先级 + UpdatedAt any // 修改时间 + State any // 记录状态 +} + +func NewHTTPDNSCustomRuleOperator() *HTTPDNSCustomRuleOperator { + return &HTTPDNSCustomRuleOperator{} +} diff --git a/EdgeAPI/internal/db/models/httpdns_custom_rule_record_dao.go b/EdgeAPI/internal/db/models/httpdns_custom_rule_record_dao.go new file mode 100644 index 0000000..58aad00 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_custom_rule_record_dao.go @@ -0,0 +1,69 @@ +package models + +import ( + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" + "github.com/iwind/TeaGo/types" +) + +const ( + HTTPDNSCustomRuleRecordStateEnabled = 1 + HTTPDNSCustomRuleRecordStateDisabled = 0 +) + +type HTTPDNSCustomRuleRecordDAO dbs.DAO + +func NewHTTPDNSCustomRuleRecordDAO() *HTTPDNSCustomRuleRecordDAO { + return dbs.NewDAO(&HTTPDNSCustomRuleRecordDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeHTTPDNSCustomRuleRecords", + Model: new(HTTPDNSCustomRuleRecord), + PkName: "id", + }, + }).(*HTTPDNSCustomRuleRecordDAO) +} + +var SharedHTTPDNSCustomRuleRecordDAO *HTTPDNSCustomRuleRecordDAO + +func init() { + dbs.OnReady(func() { + SharedHTTPDNSCustomRuleRecordDAO = NewHTTPDNSCustomRuleRecordDAO() + }) +} + +func (this *HTTPDNSCustomRuleRecordDAO) CreateRecord(tx *dbs.Tx, ruleId int64, recordType string, recordValue string, weight int32, sort int32) (int64, error) { + var op = NewHTTPDNSCustomRuleRecordOperator() + op.RuleId = ruleId + op.RecordType = recordType + op.RecordValue = recordValue + op.Weight = weight + op.Sort = sort + op.State = HTTPDNSCustomRuleRecordStateEnabled + err := this.Save(tx, op) + if err != nil { + return 0, err + } + return types.Int64(op.Id), nil +} + +func (this *HTTPDNSCustomRuleRecordDAO) DisableRecordsWithRuleId(tx *dbs.Tx, ruleId int64) error { + _, err := this.Query(tx). + Attr("ruleId", ruleId). + State(HTTPDNSCustomRuleRecordStateEnabled). + Set("state", HTTPDNSCustomRuleRecordStateDisabled). + Update() + return err +} + +func (this *HTTPDNSCustomRuleRecordDAO) ListEnabledRecordsWithRuleId(tx *dbs.Tx, ruleId int64) (result []*HTTPDNSCustomRuleRecord, err error) { + _, err = this.Query(tx). + State(HTTPDNSCustomRuleRecordStateEnabled). + Attr("ruleId", ruleId). + Asc("sort"). + AscPk(). + Slice(&result). + FindAll() + return +} diff --git a/EdgeAPI/internal/db/models/httpdns_custom_rule_record_model.go b/EdgeAPI/internal/db/models/httpdns_custom_rule_record_model.go new file mode 100644 index 0000000..c5128d0 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_custom_rule_record_model.go @@ -0,0 +1,26 @@ +package models + +// HTTPDNSCustomRuleRecord 自定义规则记录值 +type HTTPDNSCustomRuleRecord struct { + Id uint32 `field:"id"` // ID + RuleId uint32 `field:"ruleId"` // 规则ID + RecordType string `field:"recordType"` // 记录类型 + RecordValue string `field:"recordValue"` // 记录值 + Weight int32 `field:"weight"` // 权重 + Sort int32 `field:"sort"` // 顺序 + State uint8 `field:"state"` // 记录状态 +} + +type HTTPDNSCustomRuleRecordOperator struct { + Id any // ID + RuleId any // 规则ID + RecordType any // 记录类型 + RecordValue any // 记录值 + Weight any // 权重 + Sort any // 顺序 + State any // 记录状态 +} + +func NewHTTPDNSCustomRuleRecordOperator() *HTTPDNSCustomRuleRecordOperator { + return &HTTPDNSCustomRuleRecordOperator{} +} diff --git a/EdgeAPI/internal/db/models/httpdns_domain_dao.go b/EdgeAPI/internal/db/models/httpdns_domain_dao.go new file mode 100644 index 0000000..2330869 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_domain_dao.go @@ -0,0 +1,115 @@ +package models + +import ( + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" + "github.com/iwind/TeaGo/types" + "strings" + "time" +) + +const ( + HTTPDNSDomainStateEnabled = 1 + HTTPDNSDomainStateDisabled = 0 +) + +type HTTPDNSDomainDAO dbs.DAO + +func NewHTTPDNSDomainDAO() *HTTPDNSDomainDAO { + return dbs.NewDAO(&HTTPDNSDomainDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeHTTPDNSDomains", + Model: new(HTTPDNSDomain), + PkName: "id", + }, + }).(*HTTPDNSDomainDAO) +} + +var SharedHTTPDNSDomainDAO *HTTPDNSDomainDAO + +func init() { + dbs.OnReady(func() { + SharedHTTPDNSDomainDAO = NewHTTPDNSDomainDAO() + }) +} + +func (this *HTTPDNSDomainDAO) CreateDomain(tx *dbs.Tx, appDbId int64, domain string, isOn bool) (int64, error) { + domain = strings.ToLower(strings.TrimSpace(domain)) + var op = NewHTTPDNSDomainOperator() + op.AppId = appDbId + op.Domain = domain + op.IsOn = isOn + op.CreatedAt = time.Now().Unix() + op.UpdatedAt = time.Now().Unix() + op.State = HTTPDNSDomainStateEnabled + err := this.Save(tx, op) + if err != nil { + return 0, err + } + return types.Int64(op.Id), nil +} + +func (this *HTTPDNSDomainDAO) DisableDomain(tx *dbs.Tx, domainId int64) error { + _, err := this.Query(tx). + Pk(domainId). + Set("state", HTTPDNSDomainStateDisabled). + Update() + return err +} + +func (this *HTTPDNSDomainDAO) DisableDomainsWithAppId(tx *dbs.Tx, appDbId int64) error { + _, err := this.Query(tx). + Attr("appId", appDbId). + State(HTTPDNSDomainStateEnabled). + Set("state", HTTPDNSDomainStateDisabled). + Set("updatedAt", time.Now().Unix()). + Update() + return err +} + +func (this *HTTPDNSDomainDAO) UpdateDomainStatus(tx *dbs.Tx, domainId int64, isOn bool) error { + _, err := this.Query(tx). + Pk(domainId). + State(HTTPDNSDomainStateEnabled). + Set("isOn", isOn). + Set("updatedAt", time.Now().Unix()). + Update() + return err +} + +func (this *HTTPDNSDomainDAO) FindEnabledDomain(tx *dbs.Tx, domainId int64) (*HTTPDNSDomain, error) { + one, err := this.Query(tx). + Pk(domainId). + State(HTTPDNSDomainStateEnabled). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSDomain), nil +} + +func (this *HTTPDNSDomainDAO) FindEnabledDomainWithAppAndName(tx *dbs.Tx, appDbId int64, domain string) (*HTTPDNSDomain, error) { + one, err := this.Query(tx). + State(HTTPDNSDomainStateEnabled). + Attr("appId", appDbId). + Attr("domain", strings.ToLower(strings.TrimSpace(domain))). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSDomain), nil +} + +func (this *HTTPDNSDomainDAO) ListEnabledDomainsWithAppId(tx *dbs.Tx, appDbId int64, keyword string) (result []*HTTPDNSDomain, err error) { + query := this.Query(tx). + State(HTTPDNSDomainStateEnabled). + Attr("appId", appDbId). + AscPk() + if len(keyword) > 0 { + query = query.Where("domain LIKE :kw").Param("kw", "%"+keyword+"%") + } + _, err = query.Slice(&result).FindAll() + return +} diff --git a/EdgeAPI/internal/db/models/httpdns_domain_model.go b/EdgeAPI/internal/db/models/httpdns_domain_model.go new file mode 100644 index 0000000..1b96478 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_domain_model.go @@ -0,0 +1,26 @@ +package models + +// HTTPDNSDomain 应用绑定域名 +type HTTPDNSDomain struct { + Id uint32 `field:"id"` // ID + AppId uint32 `field:"appId"` // 应用DB ID + Domain string `field:"domain"` // 业务域名 + IsOn bool `field:"isOn"` // 是否启用 + CreatedAt uint64 `field:"createdAt"` // 创建时间 + UpdatedAt uint64 `field:"updatedAt"` // 修改时间 + State uint8 `field:"state"` // 记录状态 +} + +type HTTPDNSDomainOperator struct { + Id any // ID + AppId any // 应用DB ID + Domain any // 业务域名 + IsOn any // 是否启用 + CreatedAt any // 创建时间 + UpdatedAt any // 修改时间 + State any // 记录状态 +} + +func NewHTTPDNSDomainOperator() *HTTPDNSDomainOperator { + return &HTTPDNSDomainOperator{} +} diff --git a/EdgeAPI/internal/db/models/httpdns_node_dao.go b/EdgeAPI/internal/db/models/httpdns_node_dao.go new file mode 100644 index 0000000..140cc03 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_node_dao.go @@ -0,0 +1,327 @@ +package models + +import ( + "encoding/json" + "github.com/TeaOSLab/EdgeAPI/internal/utils" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" + "github.com/iwind/TeaGo/rands" + "github.com/iwind/TeaGo/types" + "time" +) + +const ( + HTTPDNSNodeStateEnabled = 1 + HTTPDNSNodeStateDisabled = 0 +) + +type HTTPDNSNodeDAO dbs.DAO + +func NewHTTPDNSNodeDAO() *HTTPDNSNodeDAO { + return dbs.NewDAO(&HTTPDNSNodeDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeHTTPDNSNodes", + Model: new(HTTPDNSNode), + PkName: "id", + }, + }).(*HTTPDNSNodeDAO) +} + +var SharedHTTPDNSNodeDAO *HTTPDNSNodeDAO + +func init() { + dbs.OnReady(func() { + SharedHTTPDNSNodeDAO = NewHTTPDNSNodeDAO() + }) +} + +// FindEnabledNodeIdWithUniqueId 鏍规嵁鍞竴ID鑾峰彇鍚敤涓殑HTTPDNS鑺傜偣ID +func (this *HTTPDNSNodeDAO) FindEnabledNodeIdWithUniqueId(tx *dbs.Tx, uniqueId string) (int64, error) { + return this.Query(tx). + Attr("uniqueId", uniqueId). + Attr("state", HTTPDNSNodeStateEnabled). + ResultPk(). + FindInt64Col(0) +} + +// CreateNode 鍒涘缓鑺傜偣 +func (this *HTTPDNSNodeDAO) CreateNode(tx *dbs.Tx, clusterId int64, name string, installDir string, isOn bool) (int64, error) { + uniqueId := rands.HexString(32) + secret := rands.String(32) + err := SharedApiTokenDAO.CreateAPIToken(tx, uniqueId, secret, nodeconfigs.NodeRoleHTTPDNS) + if err != nil { + return 0, err + } + + var op = NewHTTPDNSNodeOperator() + op.ClusterId = clusterId + op.Name = name + op.IsOn = isOn + op.IsUp = false + op.IsInstalled = false + op.IsActive = false + op.UniqueId = uniqueId + op.Secret = secret + op.InstallDir = installDir + op.CreatedAt = time.Now().Unix() + op.UpdatedAt = time.Now().Unix() + op.State = HTTPDNSNodeStateEnabled + err = this.Save(tx, op) + if err != nil { + return 0, err + } + return types.Int64(op.Id), nil +} + +// UpdateNode 鏇存柊鑺傜偣 +func (this *HTTPDNSNodeDAO) UpdateNode(tx *dbs.Tx, nodeId int64, name string, installDir string, isOn bool) error { + var op = NewHTTPDNSNodeOperator() + op.Id = nodeId + op.Name = name + op.InstallDir = installDir + op.IsOn = isOn + op.UpdatedAt = time.Now().Unix() + return this.Save(tx, op) +} + +// DisableNode 绂佺敤鑺傜偣 +func (this *HTTPDNSNodeDAO) DisableNode(tx *dbs.Tx, nodeId int64) error { + node, err := this.FindEnabledNode(tx, nodeId) + if err != nil { + return err + } + if node == nil { + return nil + } + + _, err = this.Query(tx). + Pk(nodeId). + Set("state", HTTPDNSNodeStateDisabled). + Update() + if err != nil { + return err + } + + _, err = SharedApiTokenDAO.Query(tx). + Attr("nodeId", node.UniqueId). + Attr("role", nodeconfigs.NodeRoleHTTPDNS). + Set("state", ApiTokenStateDisabled). + Update() + return err +} + +// FindEnabledNode 鏌ユ壘鍚敤鑺傜偣 +func (this *HTTPDNSNodeDAO) FindEnabledNode(tx *dbs.Tx, nodeId int64) (*HTTPDNSNode, error) { + one, err := this.Query(tx). + Pk(nodeId). + Attr("state", HTTPDNSNodeStateEnabled). + Find() + if one == nil { + return nil, err + } + return one.(*HTTPDNSNode), nil +} + +// FindNodeClusterId 鏌ヨ鑺傜偣鎵€灞為泦缇D +func (this *HTTPDNSNodeDAO) FindNodeClusterId(tx *dbs.Tx, nodeId int64) (int64, error) { + return this.Query(tx). + Pk(nodeId). + Attr("state", HTTPDNSNodeStateEnabled). + Result("clusterId"). + FindInt64Col(0) +} + +// ListEnabledNodes 鍒楀嚭鑺傜偣 +func (this *HTTPDNSNodeDAO) ListEnabledNodes(tx *dbs.Tx, clusterId int64) (result []*HTTPDNSNode, err error) { + query := this.Query(tx). + State(HTTPDNSNodeStateEnabled). + AscPk() + if clusterId > 0 { + query = query.Attr("clusterId", clusterId) + } + _, err = query.Slice(&result).FindAll() + return +} + +// FindAllInactiveNodesWithClusterId 取得一个集群离线的HTTPDNS节点 +func (this *HTTPDNSNodeDAO) FindAllInactiveNodesWithClusterId(tx *dbs.Tx, clusterId int64) (result []*HTTPDNSNode, err error) { + _, err = this.Query(tx). + State(HTTPDNSNodeStateEnabled). + Attr("clusterId", clusterId). + Attr("isOn", true). // 只监控启用的节点 + Attr("isInstalled", true). // 只监控已经安装的节点 + Attr("isActive", false). // 当前处于离线状态 + Result("id", "name"). + Slice(&result). + FindAll() + return +} + +// UpdateNodeStatus 更新节点状态 +func (this *HTTPDNSNodeDAO) UpdateNodeStatus(tx *dbs.Tx, nodeId int64, isUp bool, isInstalled bool, isActive bool, statusJSON []byte, installStatusJSON []byte) error { + var op = NewHTTPDNSNodeOperator() + op.Id = nodeId + op.IsUp = isUp + op.IsInstalled = isInstalled + op.IsActive = isActive + op.UpdatedAt = time.Now().Unix() + if len(statusJSON) > 0 { + op.Status = statusJSON + } + if len(installStatusJSON) > 0 { + mergedStatusJSON, mergeErr := this.mergeInstallStatusJSON(tx, nodeId, installStatusJSON) + if mergeErr != nil { + return mergeErr + } + op.InstallStatus = mergedStatusJSON + } + return this.Save(tx, op) +} + +// UpdateNodeInstallStatus 更新节点安装状态 +func (this *HTTPDNSNodeDAO) UpdateNodeInstallStatus(tx *dbs.Tx, nodeId int64, installStatus *NodeInstallStatus) error { + if installStatus == nil { + return nil + } + + // Read existing installStatus to preserve custom fields like 'ssh' and 'ipAddr' + raw, err := this.Query(tx).Pk(nodeId).Result("installStatus").FindBytesCol() + if err != nil { + return err + } + + var m = map[string]interface{}{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &m) + } + + // Overlay standard install status fields + statusData, err := json.Marshal(installStatus) + if err != nil { + return err + } + var newStatusMap = map[string]interface{}{} + _ = json.Unmarshal(statusData, &newStatusMap) + + for k, v := range newStatusMap { + m[k] = v + } + + // Re-marshal the merged map + mergedData, err := json.Marshal(m) + if err != nil { + return err + } + + _, err = this.Query(tx). + Pk(nodeId). + Set("installStatus", mergedData). + Set("updatedAt", time.Now().Unix()). + Update() + return err +} + +func (this *HTTPDNSNodeDAO) mergeInstallStatusJSON(tx *dbs.Tx, nodeId int64, patch []byte) ([]byte, error) { + if len(patch) == 0 { + return patch, nil + } + + raw, err := this.Query(tx).Pk(nodeId).Result("installStatus").FindBytesCol() + if err != nil { + return nil, err + } + + merged := map[string]interface{}{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &merged) + } + patchMap := map[string]interface{}{} + if len(patch) > 0 { + _ = json.Unmarshal(patch, &patchMap) + } + + for k, v := range patchMap { + merged[k] = v + } + + data, err := json.Marshal(merged) + if err != nil { + return nil, err + } + return data, nil +} + +// FindNodeInstallStatus 读取节点安装状态 +func (this *HTTPDNSNodeDAO) FindNodeInstallStatus(tx *dbs.Tx, nodeId int64) (*NodeInstallStatus, error) { + raw, err := this.Query(tx). + Pk(nodeId). + State(HTTPDNSNodeStateEnabled). + Result("installStatus"). + FindBytesCol() + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, nil + } + + installStatus := &NodeInstallStatus{} + err = json.Unmarshal(raw, installStatus) + if err != nil { + return nil, err + } + return installStatus, nil +} + +// CountAllLowerVersionNodesWithClusterId 璁$畻鍗曚釜闆嗙兢涓墍鏈変綆浜庢煇涓増鏈殑鑺傜偣鏁伴噺 +func (this *HTTPDNSNodeDAO) CountAllLowerVersionNodesWithClusterId(tx *dbs.Tx, clusterId int64, os string, arch string, version string) (int64, error) { + return this.Query(tx). + State(HTTPDNSNodeStateEnabled). + Attr("clusterId", clusterId). + Attr("isOn", true). + Attr("isUp", true). + Attr("isActive", true). + Where("status IS NOT NULL"). + Where("JSON_EXTRACT(status, '$.os')=:os"). + Where("JSON_EXTRACT(status, '$.arch')=:arch"). + Where("(JSON_EXTRACT(status, '$.buildVersionCode') IS NULL OR JSON_EXTRACT(status, '$.buildVersionCode')<:version)"). + Param("os", os). + Param("arch", arch). + Param("version", utils.VersionToLong(version)). + Count() +} + +// FindAllLowerVersionNodesWithClusterId 鏌ユ壘鍗曚釜闆嗙兢涓墍鏈変綆浜庢煇涓増鏈殑鑺傜偣 +func (this *HTTPDNSNodeDAO) FindAllLowerVersionNodesWithClusterId(tx *dbs.Tx, clusterId int64, os string, arch string, version string) (result []*HTTPDNSNode, err error) { + _, err = this.Query(tx). + State(HTTPDNSNodeStateEnabled). + Attr("clusterId", clusterId). + Attr("isOn", true). + Attr("isUp", true). + Attr("isActive", true). + Where("status IS NOT NULL"). + Where("JSON_EXTRACT(status, '$.os')=:os"). + Where("JSON_EXTRACT(status, '$.arch')=:arch"). + Where("(JSON_EXTRACT(status, '$.buildVersionCode') IS NULL OR JSON_EXTRACT(status, '$.buildVersionCode')<:version)"). + Param("os", os). + Param("arch", arch). + Param("version", utils.VersionToLong(version)). + DescPk(). + Slice(&result). + FindAll() + return +} + +// UpdateNodeIsInstalled 鏇存柊鑺傜偣瀹夎鐘舵€佷綅 +func (this *HTTPDNSNodeDAO) UpdateNodeIsInstalled(tx *dbs.Tx, nodeId int64, isInstalled bool) error { + _, err := this.Query(tx). + Pk(nodeId). + State(HTTPDNSNodeStateEnabled). + Set("isInstalled", isInstalled). + Set("updatedAt", time.Now().Unix()). + Update() + return err +} diff --git a/EdgeAPI/internal/db/models/httpdns_node_model.go b/EdgeAPI/internal/db/models/httpdns_node_model.go new file mode 100644 index 0000000..80a15de --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_node_model.go @@ -0,0 +1,44 @@ +package models + +import "github.com/iwind/TeaGo/dbs" + +// HTTPDNSNode HTTPDNS节点 +type HTTPDNSNode struct { + Id uint32 `field:"id"` // ID + ClusterId uint32 `field:"clusterId"` // 集群ID + Name string `field:"name"` // 节点名称 + IsOn bool `field:"isOn"` // 是否启用 + IsUp bool `field:"isUp"` // 是否在线 + IsInstalled bool `field:"isInstalled"` // 是否已安装 + IsActive bool `field:"isActive"` // 是否活跃 + UniqueId string `field:"uniqueId"` // 节点唯一ID + Secret string `field:"secret"` // 节点密钥 + InstallDir string `field:"installDir"` // 安装目录 + Status dbs.JSON `field:"status"` // 运行状态快照 + InstallStatus dbs.JSON `field:"installStatus"` // 安装状态 + CreatedAt uint64 `field:"createdAt"` // 创建时间 + UpdatedAt uint64 `field:"updatedAt"` // 修改时间 + State uint8 `field:"state"` // 记录状态 +} + +type HTTPDNSNodeOperator struct { + Id any // ID + ClusterId any // 集群ID + Name any // 节点名称 + IsOn any // 是否启用 + IsUp any // 是否在线 + IsInstalled any // 是否已安装 + IsActive any // 是否活跃 + UniqueId any // 节点唯一ID + Secret any // 节点密钥 + InstallDir any // 安装目录 + Status any // 运行状态快照 + InstallStatus any // 安装状态 + CreatedAt any // 创建时间 + UpdatedAt any // 修改时间 + State any // 记录状态 +} + +func NewHTTPDNSNodeOperator() *HTTPDNSNodeOperator { + return &HTTPDNSNodeOperator{} +} diff --git a/EdgeAPI/internal/db/models/httpdns_runtime_log_dao.go b/EdgeAPI/internal/db/models/httpdns_runtime_log_dao.go new file mode 100644 index 0000000..4d53581 --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_runtime_log_dao.go @@ -0,0 +1,108 @@ +package models + +import ( + "strconv" + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" +) + +type HTTPDNSRuntimeLogDAO dbs.DAO + +func NewHTTPDNSRuntimeLogDAO() *HTTPDNSRuntimeLogDAO { + return dbs.NewDAO(&HTTPDNSRuntimeLogDAO{ + DAOObject: dbs.DAOObject{ + DB: Tea.Env, + Table: "edgeHTTPDNSRuntimeLogs", + Model: new(HTTPDNSRuntimeLog), + PkName: "id", + }, + }).(*HTTPDNSRuntimeLogDAO) +} + +var SharedHTTPDNSRuntimeLogDAO *HTTPDNSRuntimeLogDAO + +func init() { + dbs.OnReady(func() { + SharedHTTPDNSRuntimeLogDAO = NewHTTPDNSRuntimeLogDAO() + }) +} + +func (this *HTTPDNSRuntimeLogDAO) CreateLog(tx *dbs.Tx, log *HTTPDNSRuntimeLog) error { + lastLog, err := this.Query(tx). + Result("id", "clusterId", "nodeId", "level", "type", "module", "description", "createdAt"). + DescPk(). + Find() + if err != nil { + return err + } + if lastLog != nil { + nodeLog := lastLog.(*HTTPDNSRuntimeLog) + if nodeLog.ClusterId == log.ClusterId && + nodeLog.NodeId == log.NodeId && + nodeLog.Level == log.Level && + nodeLog.Type == log.Type && + nodeLog.Module == log.Module && + nodeLog.Description == log.Description && + time.Now().Unix()-int64(nodeLog.CreatedAt) < 1800 { + + count := log.Count + if count <= 0 { + count = 1 + } + + return this.Query(tx). + Pk(nodeLog.Id). + Set("count", dbs.SQL("count+"+strconv.FormatInt(count, 10))). + UpdateQuickly() + } + } + + var op = NewHTTPDNSRuntimeLogOperator() + op.ClusterId = log.ClusterId + op.NodeId = log.NodeId + op.Level = log.Level + op.Type = log.Type + op.Module = log.Module + op.Description = log.Description + op.Count = log.Count + op.RequestId = log.RequestId + op.CreatedAt = log.CreatedAt + op.Day = log.Day + return this.Save(tx, op) +} + +func (this *HTTPDNSRuntimeLogDAO) BuildListQuery(tx *dbs.Tx, day string, clusterId int64, nodeId int64, level string, keyword string) *dbs.Query { + query := this.Query(tx).DescPk() + if len(day) > 0 { + query = query.Attr("day", day) + } + if clusterId > 0 { + query = query.Attr("clusterId", clusterId) + } + if nodeId > 0 { + query = query.Attr("nodeId", nodeId) + } + if len(level) > 0 { + query = query.Attr("level", level) + } + if len(keyword) > 0 { + query = query.Where("(type LIKE :kw OR module LIKE :kw OR description LIKE :kw OR requestId LIKE :kw)").Param("kw", "%"+keyword+"%") + } + return query +} + +func (this *HTTPDNSRuntimeLogDAO) CountLogs(tx *dbs.Tx, day string, clusterId int64, nodeId int64, level string, keyword string) (int64, error) { + return this.BuildListQuery(tx, day, clusterId, nodeId, level, keyword).Count() +} + +func (this *HTTPDNSRuntimeLogDAO) ListLogs(tx *dbs.Tx, day string, clusterId int64, nodeId int64, level string, keyword string, offset int64, size int64) (result []*HTTPDNSRuntimeLog, err error) { + _, err = this.BuildListQuery(tx, day, clusterId, nodeId, level, keyword). + Offset(offset). + Limit(size). + Slice(&result). + FindAll() + return +} diff --git a/EdgeAPI/internal/db/models/httpdns_runtime_log_model.go b/EdgeAPI/internal/db/models/httpdns_runtime_log_model.go new file mode 100644 index 0000000..fe26dda --- /dev/null +++ b/EdgeAPI/internal/db/models/httpdns_runtime_log_model.go @@ -0,0 +1,34 @@ +package models + +// HTTPDNSRuntimeLog 运行日志 +type HTTPDNSRuntimeLog struct { + Id uint64 `field:"id"` // ID + ClusterId uint32 `field:"clusterId"` // 集群ID + NodeId uint32 `field:"nodeId"` // 节点ID + Level string `field:"level"` // 级别 + Type string `field:"type"` // 类型 + Module string `field:"module"` // 模块 + Description string `field:"description"` // 详情 + Count int64 `field:"count"` // 次数 + RequestId string `field:"requestId"` // 请求ID + CreatedAt uint64 `field:"createdAt"` // 创建时间 + Day string `field:"day"` // YYYYMMDD +} + +type HTTPDNSRuntimeLogOperator struct { + Id any // ID + ClusterId any // 集群ID + NodeId any // 节点ID + Level any // 级别 + Type any // 类型 + Module any // 模块 + Description any // 详情 + Count any // 次数 + RequestId any // 请求ID + CreatedAt any // 创建时间 + Day any // YYYYMMDD +} + +func NewHTTPDNSRuntimeLogOperator() *HTTPDNSRuntimeLogOperator { + return &HTTPDNSRuntimeLogOperator{} +} diff --git a/EdgeAPI/internal/db/models/node_dao.go b/EdgeAPI/internal/db/models/node_dao.go index e032c6b..8f58027 100644 --- a/EdgeAPI/internal/db/models/node_dao.go +++ b/EdgeAPI/internal/db/models/node_dao.go @@ -1521,6 +1521,8 @@ func (this *NodeDAO) CountAllLowerVersionNodesWithClusterId(tx *dbs.Tx, clusterI return this.Query(tx). State(NodeStateEnabled). Attr("isOn", true). + Attr("isUp", true). + Attr("isActive", true). Attr("clusterId", clusterId). Where("status IS NOT NULL"). Where("JSON_EXTRACT(status, '$.os')=:os"). @@ -1536,6 +1538,9 @@ func (this *NodeDAO) CountAllLowerVersionNodesWithClusterId(tx *dbs.Tx, clusterI func (this *NodeDAO) FindAllLowerVersionNodesWithClusterId(tx *dbs.Tx, clusterId int64, os string, arch string, version string) (result []*Node, err error) { _, err = this.Query(tx). State(NodeStateEnabled). + Attr("isOn", true). + Attr("isUp", true). + Attr("isActive", true). Attr("clusterId", clusterId). Where("status IS NOT NULL"). Where("JSON_EXTRACT(status, '$.os')=:os"). diff --git a/EdgeAPI/internal/db/models/node_task_dao.go b/EdgeAPI/internal/db/models/node_task_dao.go index df317dd..1a3d90a 100644 --- a/EdgeAPI/internal/db/models/node_task_dao.go +++ b/EdgeAPI/internal/db/models/node_task_dao.go @@ -15,35 +15,41 @@ import ( type NodeTaskType = string const ( - // CDN相关 + // CDN鐩稿叧 - NodeTaskTypeConfigChanged NodeTaskType = "configChanged" // 节点整体配置变化 - NodeTaskTypeDDosProtectionChanged NodeTaskType = "ddosProtectionChanged" // 节点DDoS配置变更 - NodeTaskTypeGlobalServerConfigChanged NodeTaskType = "globalServerConfigChanged" // 全局服务设置变化 - NodeTaskTypeIPListDeleted NodeTaskType = "ipListDeleted" // IPList被删除 - NodeTaskTypeIPItemChanged NodeTaskType = "ipItemChanged" // IP条目变更 - NodeTaskTypeNodeVersionChanged NodeTaskType = "nodeVersionChanged" // 节点版本变化 - NodeTaskTypeScriptsChanged NodeTaskType = "scriptsChanged" // 脚本配置变化 - NodeTaskTypeNodeLevelChanged NodeTaskType = "nodeLevelChanged" // 节点级别变化 - NodeTaskTypeUserServersStateChanged NodeTaskType = "userServersStateChanged" // 用户服务状态变化 - NodeTaskTypeUAMPolicyChanged NodeTaskType = "uamPolicyChanged" // UAM策略变化 - NodeTaskTypeHTTPPagesPolicyChanged NodeTaskType = "httpPagesPolicyChanged" // 自定义页面变化 - NodeTaskTypeHTTPCCPolicyChanged NodeTaskType = "httpCCPolicyChanged" // CC策略变化 - NodeTaskTypeHTTP3PolicyChanged NodeTaskType = "http3PolicyChanged" // HTTP3策略变化 - NodeTaskTypeNetworkSecurityPolicyChanged NodeTaskType = "networkSecurityPolicyChanged" // 网络安全策略变化 - NodeTaskTypeWebPPolicyChanged NodeTaskType = "webPPolicyChanged" // WebP策略变化 - NodeTaskTypeUpdatingServers NodeTaskType = "updatingServers" // 更新一组服务 - NodeTaskTypeTOAChanged NodeTaskType = "toaChanged" // TOA配置变化 - NodeTaskTypePlanChanged NodeTaskType = "planChanged" // 套餐变化 + NodeTaskTypeConfigChanged NodeTaskType = "configChanged" // 鑺傜偣鏁翠綋閰嶇疆鍙樺寲 + NodeTaskTypeDDosProtectionChanged NodeTaskType = "ddosProtectionChanged" // 鑺傜偣DDoS閰嶇疆鍙樻洿 + NodeTaskTypeGlobalServerConfigChanged NodeTaskType = "globalServerConfigChanged" // 鍏ㄥ眬鏈嶅姟璁剧疆鍙樺寲 + NodeTaskTypeIPListDeleted NodeTaskType = "ipListDeleted" // IPList琚垹闄? + NodeTaskTypeIPItemChanged NodeTaskType = "ipItemChanged" // IP鏉$洰鍙樻洿 + NodeTaskTypeNodeVersionChanged NodeTaskType = "nodeVersionChanged" // 鑺傜偣鐗堟湰鍙樺寲 + NodeTaskTypeScriptsChanged NodeTaskType = "scriptsChanged" // 鑴氭湰閰嶇疆鍙樺寲 + NodeTaskTypeNodeLevelChanged NodeTaskType = "nodeLevelChanged" // 鑺傜偣绾у埆鍙樺寲 + NodeTaskTypeUserServersStateChanged NodeTaskType = "userServersStateChanged" // 鐢ㄦ埛鏈嶅姟鐘舵€佸彉鍖? + NodeTaskTypeUAMPolicyChanged NodeTaskType = "uamPolicyChanged" // UAM绛栫暐鍙樺寲 + NodeTaskTypeHTTPPagesPolicyChanged NodeTaskType = "httpPagesPolicyChanged" // 鑷畾涔夐〉闈㈠彉鍖? + NodeTaskTypeHTTPCCPolicyChanged NodeTaskType = "httpCCPolicyChanged" // CC绛栫暐鍙樺寲 + NodeTaskTypeHTTP3PolicyChanged NodeTaskType = "http3PolicyChanged" // HTTP3绛栫暐鍙樺寲 + NodeTaskTypeNetworkSecurityPolicyChanged NodeTaskType = "networkSecurityPolicyChanged" // 缃戠粶瀹夊叏绛栫暐鍙樺寲 + NodeTaskTypeWebPPolicyChanged NodeTaskType = "webPPolicyChanged" // WebP绛栫暐鍙樺寲 + NodeTaskTypeUpdatingServers NodeTaskType = "updatingServers" // 鏇存柊涓€缁勬湇鍔? + NodeTaskTypeTOAChanged NodeTaskType = "toaChanged" // TOA閰嶇疆鍙樺寲 + NodeTaskTypePlanChanged NodeTaskType = "planChanged" // 濂楅鍙樺寲 - // NS相关 + // NS鐩稿叧 NSNodeTaskTypeConfigChanged NodeTaskType = "nsConfigChanged" NSNodeTaskTypeDomainChanged NodeTaskType = "nsDomainChanged" NSNodeTaskTypeRecordChanged NodeTaskType = "nsRecordChanged" NSNodeTaskTypeRouteChanged NodeTaskType = "nsRouteChanged" NSNodeTaskTypeKeyChanged NodeTaskType = "nsKeyChanged" - NSNodeTaskTypeDDosProtectionChanged NodeTaskType = "nsDDoSProtectionChanged" // 节点DDoS配置变更 + NSNodeTaskTypeDDosProtectionChanged NodeTaskType = "nsDDoSProtectionChanged" // 鑺傜偣DDoS閰嶇疆鍙樻洿 + // HTTPDNS相关 + HTTPDNSNodeTaskTypeConfigChanged NodeTaskType = "httpdnsConfigChanged" + HTTPDNSNodeTaskTypeAppChanged NodeTaskType = "httpdnsAppChanged" + HTTPDNSNodeTaskTypeDomainChanged NodeTaskType = "httpdnsDomainChanged" + HTTPDNSNodeTaskTypeRuleChanged NodeTaskType = "httpdnsRuleChanged" + HTTPDNSNodeTaskTypeTLSChanged NodeTaskType = "httpdnsTLSChanged" ) type NodeTaskDAO dbs.DAO @@ -67,15 +73,15 @@ func init() { }) } -// CreateNodeTask 创建单个节点任务 +// CreateNodeTask 鍒涘缓鍗曚釜鑺傜偣浠诲姟 func (this *NodeTaskDAO) CreateNodeTask(tx *dbs.Tx, role string, clusterId int64, nodeId int64, userId int64, serverId int64, taskType NodeTaskType) error { if clusterId <= 0 || nodeId <= 0 { return nil } var uniqueId = role + "@" + types.String(nodeId) + "@node@" + types.String(serverId) + "@" + taskType - // 用户信息 - // 没有直接加入到 uniqueId 中,是为了兼容以前的字段值 + // 鐢ㄦ埛淇℃伅 + // 娌℃湁鐩存帴鍔犲叆鍒?uniqueId 涓紝鏄负浜嗗吋瀹逛互鍓嶇殑瀛楁鍊? if userId > 0 { uniqueId += "@" + types.String(userId) } @@ -113,7 +119,7 @@ func (this *NodeTaskDAO) CreateNodeTask(tx *dbs.Tx, role string, clusterId int64 return err } -// CreateClusterTask 创建集群任务 +// CreateClusterTask 鍒涘缓闆嗙兢浠诲姟 func (this *NodeTaskDAO) CreateClusterTask(tx *dbs.Tx, role string, clusterId int64, userId int64, serverId int64, taskType NodeTaskType) error { if clusterId <= 0 { return nil @@ -121,8 +127,8 @@ func (this *NodeTaskDAO) CreateClusterTask(tx *dbs.Tx, role string, clusterId in var uniqueId = role + "@" + types.String(clusterId) + "@" + types.String(serverId) + "@cluster@" + taskType - // 用户信息 - // 没有直接加入到 uniqueId 中,是为了兼容以前的字段值 + // 鐢ㄦ埛淇℃伅 + // 娌℃湁鐩存帴鍔犲叆鍒?uniqueId 涓紝鏄负浜嗗吋瀹逛互鍓嶇殑瀛楁鍊? if userId > 0 { uniqueId += "@" + types.String(userId) } @@ -155,7 +161,7 @@ func (this *NodeTaskDAO) CreateClusterTask(tx *dbs.Tx, role string, clusterId in return err } -// ExtractNodeClusterTask 分解边缘节点集群任务 +// ExtractNodeClusterTask 鍒嗚В杈圭紭鑺傜偣闆嗙兢浠诲姟 func (this *NodeTaskDAO) ExtractNodeClusterTask(tx *dbs.Tx, clusterId int64, userId int64, serverId int64, taskType NodeTaskType) error { nodeIds, err := SharedNodeDAO.FindAllNodeIdsMatch(tx, clusterId, true, configutils.BoolStateYes) if err != nil { @@ -193,7 +199,7 @@ func (this *NodeTaskDAO) ExtractNodeClusterTask(tx *dbs.Tx, clusterId int64, use return nil } -// ExtractAllClusterTasks 分解所有集群任务 +// ExtractAllClusterTasks 鍒嗚В鎵€鏈夐泦缇や换鍔? func (this *NodeTaskDAO) ExtractAllClusterTasks(tx *dbs.Tx, role string) error { ones, err := this.Query(tx). Attr("role", role). @@ -216,12 +222,17 @@ func (this *NodeTaskDAO) ExtractAllClusterTasks(tx *dbs.Tx, role string) error { if err != nil { return err } + case nodeconfigs.NodeRoleHTTPDNS: + err = this.ExtractHTTPDNSClusterTask(tx, clusterId, one.(*NodeTask).Type) + if err != nil { + return err + } } } return nil } -// DeleteAllClusterTasks 删除集群所有相关任务 +// DeleteAllClusterTasks 鍒犻櫎闆嗙兢鎵€鏈夌浉鍏充换鍔? func (this *NodeTaskDAO) DeleteAllClusterTasks(tx *dbs.Tx, role string, clusterId int64) error { _, err := this.Query(tx). Attr("role", role). @@ -230,7 +241,7 @@ func (this *NodeTaskDAO) DeleteAllClusterTasks(tx *dbs.Tx, role string, clusterI return err } -// DeleteNodeTasks 删除节点相关任务 +// DeleteNodeTasks 鍒犻櫎鑺傜偣鐩稿叧浠诲姟 func (this *NodeTaskDAO) DeleteNodeTasks(tx *dbs.Tx, role string, nodeId int64) error { _, err := this.Query(tx). Attr("role", role). @@ -239,13 +250,13 @@ func (this *NodeTaskDAO) DeleteNodeTasks(tx *dbs.Tx, role string, nodeId int64) return err } -// DeleteAllNodeTasks 删除所有节点相关任务 +// DeleteAllNodeTasks 鍒犻櫎鎵€鏈夎妭鐐圭浉鍏充换鍔? func (this *NodeTaskDAO) DeleteAllNodeTasks(tx *dbs.Tx) error { return this.Query(tx). DeleteQuickly() } -// FindDoingNodeTasks 查询一个节点的所有任务 +// FindDoingNodeTasks 鏌ヨ涓€涓妭鐐圭殑鎵€鏈変换鍔? func (this *NodeTaskDAO) FindDoingNodeTasks(tx *dbs.Tx, role string, nodeId int64, version int64) (result []*NodeTask, err error) { if nodeId <= 0 { return @@ -256,10 +267,10 @@ func (this *NodeTaskDAO) FindDoingNodeTasks(tx *dbs.Tx, role string, nodeId int6 UseIndex("nodeId"). Asc("version") if version > 0 { - query.Lt("LENGTH(version)", 19) // 兼容以往版本 + query.Lt("LENGTH(version)", 19) // 鍏煎浠ュ線鐗堟湰 query.Gt("version", version) } else { - // 第一次访问时只取当前正在执行的或者执行失败的 + // 绗竴娆¤闂椂鍙彇褰撳墠姝e湪鎵ц鐨勬垨鑰呮墽琛屽け璐ョ殑 query.Where("(isDone=0 OR (isDone=1 AND isOk=0))") } _, err = query. @@ -268,10 +279,10 @@ func (this *NodeTaskDAO) FindDoingNodeTasks(tx *dbs.Tx, role string, nodeId int6 return } -// UpdateNodeTaskDone 修改节点任务的完成状态 +// UpdateNodeTaskDone 淇敼鑺傜偣浠诲姟鐨勫畬鎴愮姸鎬? func (this *NodeTaskDAO) UpdateNodeTaskDone(tx *dbs.Tx, taskId int64, isOk bool, errorMessage string) error { if isOk { - // 特殊任务删除 + // 鐗规畩浠诲姟鍒犻櫎 taskType, err := this.Query(tx). Pk(taskId). Result("type"). @@ -286,7 +297,7 @@ func (this *NodeTaskDAO) UpdateNodeTaskDone(tx *dbs.Tx, taskId int64, isOk bool, } } - // 其他任务标记为完成 + // 鍏朵粬浠诲姟鏍囪涓哄畬鎴? var query = this.Query(tx). Pk(taskId) if !isOk { @@ -305,7 +316,7 @@ func (this *NodeTaskDAO) UpdateNodeTaskDone(tx *dbs.Tx, taskId int64, isOk bool, return err } -// FindAllDoingTaskClusterIds 查找正在更新的集群IDs +// FindAllDoingTaskClusterIds 鏌ユ壘姝e湪鏇存柊鐨勯泦缇Ds func (this *NodeTaskDAO) FindAllDoingTaskClusterIds(tx *dbs.Tx, role string) ([]int64, error) { ones, _, err := this.Query(tx). Result("DISTINCT(clusterId) AS clusterId"). @@ -322,7 +333,7 @@ func (this *NodeTaskDAO) FindAllDoingTaskClusterIds(tx *dbs.Tx, role string) ([] return result, nil } -// FindAllDoingNodeTasksWithClusterId 查询某个集群下所有的任务 +// FindAllDoingNodeTasksWithClusterId 鏌ヨ鏌愪釜闆嗙兢涓嬫墍鏈夌殑浠诲姟 func (this *NodeTaskDAO) FindAllDoingNodeTasksWithClusterId(tx *dbs.Tx, role string, clusterId int64) (result []*NodeTask, err error) { _, err = this.Query(tx). Attr("role", role). @@ -337,7 +348,7 @@ func (this *NodeTaskDAO) FindAllDoingNodeTasksWithClusterId(tx *dbs.Tx, role str return } -// FindAllDoingNodeIds 查询有任务的节点IDs +// FindAllDoingNodeIds 鏌ヨ鏈変换鍔$殑鑺傜偣IDs func (this *NodeTaskDAO) FindAllDoingNodeIds(tx *dbs.Tx, role string) ([]int64, error) { ones, err := this.Query(tx). Result("DISTINCT(nodeId) AS nodeId"). @@ -356,7 +367,7 @@ func (this *NodeTaskDAO) FindAllDoingNodeIds(tx *dbs.Tx, role string) ([]int64, return result, nil } -// ExistsDoingNodeTasks 检查是否有正在执行的任务 +// ExistsDoingNodeTasks 妫€鏌ユ槸鍚︽湁姝e湪鎵ц鐨勪换鍔? func (this *NodeTaskDAO) ExistsDoingNodeTasks(tx *dbs.Tx, role string, excludeTypes []NodeTaskType) (bool, error) { var query = this.Query(tx). Attr("role", role). @@ -370,7 +381,7 @@ func (this *NodeTaskDAO) ExistsDoingNodeTasks(tx *dbs.Tx, role string, excludeTy return query.Exist() } -// ExistsErrorNodeTasks 是否有错误的任务 +// ExistsErrorNodeTasks 鏄惁鏈夐敊璇殑浠诲姟 func (this *NodeTaskDAO) ExistsErrorNodeTasks(tx *dbs.Tx, role string, excludeTypes []NodeTaskType) (bool, error) { var query = this.Query(tx). Attr("role", role). @@ -383,7 +394,7 @@ func (this *NodeTaskDAO) ExistsErrorNodeTasks(tx *dbs.Tx, role string, excludeTy return query.Exist() } -// DeleteNodeTask 删除任务 +// DeleteNodeTask 鍒犻櫎浠诲姟 func (this *NodeTaskDAO) DeleteNodeTask(tx *dbs.Tx, taskId int64) error { _, err := this.Query(tx). Pk(taskId). @@ -391,7 +402,7 @@ func (this *NodeTaskDAO) DeleteNodeTask(tx *dbs.Tx, taskId int64) error { return err } -// CountDoingNodeTasks 计算正在执行的任务 +// CountDoingNodeTasks 璁$畻姝e湪鎵ц鐨勪换鍔? func (this *NodeTaskDAO) CountDoingNodeTasks(tx *dbs.Tx, role string) (int64, error) { return this.Query(tx). Attr("isDone", 0). @@ -400,7 +411,7 @@ func (this *NodeTaskDAO) CountDoingNodeTasks(tx *dbs.Tx, role string) (int64, er Count() } -// FindNotifyingNodeTasks 查找需要通知的任务 +// FindNotifyingNodeTasks 鏌ユ壘闇€瑕侀€氱煡鐨勪换鍔? func (this *NodeTaskDAO) FindNotifyingNodeTasks(tx *dbs.Tx, role string, size int64) (result []*NodeTask, err error) { _, err = this.Query(tx). Attr("role", role). @@ -413,7 +424,7 @@ func (this *NodeTaskDAO) FindNotifyingNodeTasks(tx *dbs.Tx, role string, size in return } -// UpdateTasksNotified 设置任务已通知 +// UpdateTasksNotified 璁剧疆浠诲姟宸查€氱煡 func (this *NodeTaskDAO) UpdateTasksNotified(tx *dbs.Tx, taskIds []int64) error { if len(taskIds) == 0 { return nil @@ -430,7 +441,7 @@ func (this *NodeTaskDAO) UpdateTasksNotified(tx *dbs.Tx, taskIds []int64) error return nil } -// 生成一个版本号 +// 鐢熸垚涓€涓増鏈彿 func (this *NodeTaskDAO) increaseVersion(tx *dbs.Tx) (version int64, err error) { return SharedSysLockerDAO.Increase(tx, "NODE_TASK_VERSION", 0) } diff --git a/EdgeAPI/internal/db/models/node_task_dao_httpdns.go b/EdgeAPI/internal/db/models/node_task_dao_httpdns.go new file mode 100644 index 0000000..b07a685 --- /dev/null +++ b/EdgeAPI/internal/db/models/node_task_dao_httpdns.go @@ -0,0 +1,47 @@ +package models + +import ( + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + "github.com/iwind/TeaGo/dbs" +) + +// ExtractHTTPDNSClusterTask 分解HTTPDNS节点集群任务 +func (this *NodeTaskDAO) ExtractHTTPDNSClusterTask(tx *dbs.Tx, clusterId int64, taskType NodeTaskType) error { + nodes, err := SharedHTTPDNSNodeDAO.ListEnabledNodes(tx, clusterId) + if err != nil { + return err + } + + _, err = this.Query(tx). + Attr("role", nodeconfigs.NodeRoleHTTPDNS). + Attr("clusterId", clusterId). + Gt("nodeId", 0). + Attr("type", taskType). + Delete() + if err != nil { + return err + } + + for _, node := range nodes { + if !node.IsOn { + continue + } + + err = this.CreateNodeTask(tx, nodeconfigs.NodeRoleHTTPDNS, clusterId, int64(node.Id), 0, 0, taskType) + if err != nil { + return err + } + } + + _, err = this.Query(tx). + Attr("role", nodeconfigs.NodeRoleHTTPDNS). + Attr("clusterId", clusterId). + Attr("nodeId", 0). + Attr("type", taskType). + Delete() + if err != nil { + return err + } + + return nil +} diff --git a/EdgeAPI/internal/db/models/ns_node_dao.go b/EdgeAPI/internal/db/models/ns_node_dao.go index 278b8ad..bea3054 100644 --- a/EdgeAPI/internal/db/models/ns_node_dao.go +++ b/EdgeAPI/internal/db/models/ns_node_dao.go @@ -94,6 +94,8 @@ func (this *NSNodeDAO) CountAllLowerVersionNodesWithClusterId(tx *dbs.Tx, cluste State(NSNodeStateEnabled). Attr("clusterId", clusterId). Attr("isOn", true). + Attr("isUp", true). + Attr("isActive", true). Where("status IS NOT NULL"). Where("JSON_EXTRACT(status, '$.os')=:os"). Where("JSON_EXTRACT(status, '$.arch')=:arch"). @@ -104,6 +106,27 @@ func (this *NSNodeDAO) CountAllLowerVersionNodesWithClusterId(tx *dbs.Tx, cluste Count() } +// FindAllLowerVersionNodesWithClusterId 查找单个集群中所有低于某个版本的节点 +func (this *NSNodeDAO) FindAllLowerVersionNodesWithClusterId(tx *dbs.Tx, clusterId int64, os string, arch string, version string) (result []*NSNode, err error) { + _, err = this.Query(tx). + State(NSNodeStateEnabled). + Attr("clusterId", clusterId). + Attr("isOn", true). + Attr("isUp", true). + Attr("isActive", true). + Where("status IS NOT NULL"). + Where("JSON_EXTRACT(status, '$.os')=:os"). + Where("JSON_EXTRACT(status, '$.arch')=:arch"). + Where("(JSON_EXTRACT(status, '$.buildVersionCode') IS NULL OR JSON_EXTRACT(status, '$.buildVersionCode')<:version)"). + Param("os", os). + Param("arch", arch). + Param("version", utils.VersionToLong(version)). + DescPk(). + Slice(&result). + FindAll() + return +} + // FindEnabledNodeIdWithUniqueId 根据唯一ID获取节点ID func (this *NSNodeDAO) FindEnabledNodeIdWithUniqueId(tx *dbs.Tx, uniqueId string) (int64, error) { return this.Query(tx). diff --git a/EdgeAPI/internal/db/models/ns_node_dao_plus.go b/EdgeAPI/internal/db/models/ns_node_dao_plus.go index d74fd32..4a34786 100644 --- a/EdgeAPI/internal/db/models/ns_node_dao_plus.go +++ b/EdgeAPI/internal/db/models/ns_node_dao_plus.go @@ -209,6 +209,8 @@ func (this *NSNodeDAO) CountAllLowerVersionNodesWithClusterId(tx *dbs.Tx, cluste return this.Query(tx). State(NSNodeStateEnabled). Attr("isOn", true). + Attr("isUp", true). + Attr("isActive", true). Attr("clusterId", clusterId). Where("status IS NOT NULL"). Where("JSON_EXTRACT(status, '$.os')=:os"). @@ -412,6 +414,27 @@ func (this *NSNodeDAO) CountAllLowerVersionNodes(tx *dbs.Tx, version string) (in Count() } +// FindAllLowerVersionNodesWithClusterId 查找单个集群中所有低于某个版本的节点 +func (this *NSNodeDAO) FindAllLowerVersionNodesWithClusterId(tx *dbs.Tx, clusterId int64, os string, arch string, version string) (result []*NSNode, err error) { + _, err = this.Query(tx). + State(NSNodeStateEnabled). + Attr("clusterId", clusterId). + Attr("isOn", true). + Attr("isUp", true). + Attr("isActive", true). + Where("status IS NOT NULL"). + Where("JSON_EXTRACT(status, '$.os')=:os"). + Where("JSON_EXTRACT(status, '$.arch')=:arch"). + Where("(JSON_EXTRACT(status, '$.buildVersionCode') IS NULL OR JSON_EXTRACT(status, '$.buildVersionCode')<:version)"). + Param("os", os). + Param("arch", arch). + Param("version", utils.VersionToLong(version)). + DescPk(). + Slice(&result). + FindAll() + return +} + // ComposeNodeConfig 组合节点配置 func (this *NSNodeDAO) ComposeNodeConfig(tx *dbs.Tx, nodeId int64) (*dnsconfigs.NSNodeConfig, error) { if nodeId <= 0 { diff --git a/EdgeAPI/internal/db/models/user_dao.go b/EdgeAPI/internal/db/models/user_dao.go index f4a4a66..0a495b7 100644 --- a/EdgeAPI/internal/db/models/user_dao.go +++ b/EdgeAPI/internal/db/models/user_dao.go @@ -246,7 +246,7 @@ func (this *UserDAO) CreateUser(tx *dbs.Tx, username string, } // UpdateUser 修改用户 -func (this *UserDAO) UpdateUser(tx *dbs.Tx, userId int64, username string, password string, fullname string, mobile string, tel string, email string, remark string, isOn bool, nodeClusterId int64, bandwidthAlgo systemconfigs.BandwidthAlgo) error { +func (this *UserDAO) UpdateUser(tx *dbs.Tx, userId int64, username string, password string, fullname string, mobile string, tel string, email string, remark string, isOn bool, nodeClusterId int64, bandwidthAlgo systemconfigs.BandwidthAlgo, httpdnsClusterIdsJSON []byte) error { if userId <= 0 { return errors.New("invalid userId") } @@ -265,6 +265,11 @@ func (this *UserDAO) UpdateUser(tx *dbs.Tx, userId int64, username string, passw op.ClusterId = nodeClusterId op.BandwidthAlgo = bandwidthAlgo op.IsOn = isOn + if len(httpdnsClusterIdsJSON) > 0 { + op.HttpdnsClusterIds = string(httpdnsClusterIdsJSON) + } else { + op.HttpdnsClusterIds = "[]" + } err := this.Save(tx, op) if err != nil { return err @@ -466,6 +471,21 @@ func (this *UserDAO) FindUserClusterId(tx *dbs.Tx, userId int64) (int64, error) FindInt64Col(0) } +// UpdateUserHttpdnsClusterIds 更新用户的HTTPDNS关联集群ID列表 +func (this *UserDAO) UpdateUserHttpdnsClusterIds(tx *dbs.Tx, userId int64, httpdnsClusterIdsJSON []byte) error { + if userId <= 0 { + return errors.New("invalid userId") + } + if len(httpdnsClusterIdsJSON) == 0 { + httpdnsClusterIdsJSON = []byte("[]") + } + _, err := this.Query(tx). + Pk(userId). + Set("httpdnsClusterIds", httpdnsClusterIdsJSON). + Update() + return err +} + // UpdateUserFeatures 更新单个用户Features func (this *UserDAO) UpdateUserFeatures(tx *dbs.Tx, userId int64, featuresJSON []byte) error { if userId <= 0 { diff --git a/EdgeAPI/internal/db/models/user_model.go b/EdgeAPI/internal/db/models/user_model.go index d259100..ed85eb6 100644 --- a/EdgeAPI/internal/db/models/user_model.go +++ b/EdgeAPI/internal/db/models/user_model.go @@ -37,6 +37,7 @@ const ( UserField_BandwidthAlgo dbs.FieldName = "bandwidthAlgo" // 带宽算法 UserField_BandwidthModifier dbs.FieldName = "bandwidthModifier" // 带宽修正值 UserField_Lang dbs.FieldName = "lang" // 语言代号 + UserField_HttpdnsClusterIds dbs.FieldName = "httpdnsClusterIds" // HTTPDNS关联集群ID列表 ) // User 用户 @@ -75,6 +76,7 @@ type User struct { BandwidthAlgo string `field:"bandwidthAlgo"` // 带宽算法 BandwidthModifier float64 `field:"bandwidthModifier"` // 带宽修正值 Lang string `field:"lang"` // 语言代号 + HttpdnsClusterIds dbs.JSON `field:"httpdnsClusterIds"` // HTTPDNS关联集群ID列表 } type UserOperator struct { @@ -112,6 +114,7 @@ type UserOperator struct { BandwidthAlgo any // 带宽算法 BandwidthModifier any // 带宽修正值 Lang any // 语言代号 + HttpdnsClusterIds any // HTTPDNS关联集群ID列表 } func NewUserOperator() *UserOperator { diff --git a/EdgeAPI/internal/installers/deploy_manager.go b/EdgeAPI/internal/installers/deploy_manager.go index 90a5cce..3ea01f2 100644 --- a/EdgeAPI/internal/installers/deploy_manager.go +++ b/EdgeAPI/internal/installers/deploy_manager.go @@ -15,8 +15,9 @@ var SharedDeployManager = NewDeployManager() type DeployManager struct { dir string - nodeFiles []*DeployFile - nsNodeFiles []*DeployFile + nodeFiles []*DeployFile + nsNodeFiles []*DeployFile + httpdnsNodeFiles []*DeployFile locker sync.Mutex } @@ -28,6 +29,7 @@ func NewDeployManager() *DeployManager { } manager.LoadNodeFiles() manager.LoadNSNodeFiles() + manager.LoadHTTPDNSNodeFiles() return manager } @@ -141,6 +143,61 @@ func (this *DeployManager) FindNSNodeFile(os string, arch string) *DeployFile { return nil } +// LoadHTTPDNSNodeFiles 加载所有HTTPDNS节点安装文件 +func (this *DeployManager) LoadHTTPDNSNodeFiles() []*DeployFile { + this.locker.Lock() + defer this.locker.Unlock() + + if len(this.httpdnsNodeFiles) > 0 { + return this.httpdnsNodeFiles + } + + var keyMap = map[string]*DeployFile{} // key => File + + var reg = regexp.MustCompile(`^edge-httpdns-(\w+)-(\w+)-v([0-9.]+)\.zip$`) + for _, file := range files.NewFile(this.dir).List() { + var name = file.Name() + if !reg.MatchString(name) { + continue + } + var matches = reg.FindStringSubmatch(name) + var osName = matches[1] + var arch = matches[2] + var version = matches[3] + + var key = osName + "_" + arch + oldFile, ok := keyMap[key] + if ok && stringutil.VersionCompare(oldFile.Version, version) > 0 { + continue + } + keyMap[key] = &DeployFile{ + OS: osName, + Arch: arch, + Version: version, + Path: file.Path(), + } + } + + var result = []*DeployFile{} + for _, v := range keyMap { + result = append(result, v) + } + + this.httpdnsNodeFiles = result + + return result +} + +// FindHTTPDNSNodeFile 查找特定平台的HTTPDNS节点安装文件 +func (this *DeployManager) FindHTTPDNSNodeFile(os string, arch string) *DeployFile { + for _, file := range this.LoadHTTPDNSNodeFiles() { + if file.OS == os && file.Arch == arch { + return file + } + } + return nil +} + // Reload 重置缓存 func (this *DeployManager) Reload() { this.locker.Lock() @@ -148,4 +205,5 @@ func (this *DeployManager) Reload() { this.nodeFiles = nil this.nsNodeFiles = nil + this.httpdnsNodeFiles = nil } diff --git a/EdgeAPI/internal/installers/fluent_bit.go b/EdgeAPI/internal/installers/fluent_bit.go index 2272230..9357b1d 100644 --- a/EdgeAPI/internal/installers/fluent_bit.go +++ b/EdgeAPI/internal/installers/fluent_bit.go @@ -33,11 +33,13 @@ const ( fluentBitServiceName = "fluent-bit" fluentBitDefaultBinPath = "/opt/fluent-bit/bin/fluent-bit" fluentBitLocalPackagesRoot = "packages" - fluentBitHTTPPathPattern = "/var/log/edge/edge-node/*.log" - fluentBitDNSPathPattern = "/var/log/edge/edge-dns/*.log" - fluentBitManagedMarker = "managed-by-edgeapi" - fluentBitRoleNode = "node" - fluentBitRoleDNS = "dns" + fluentBitHTTPPathPattern = "/var/log/edge/edge-node/*.log" + fluentBitDNSPathPattern = "/var/log/edge/edge-dns/*.log" + fluentBitHTTPDNSPathPattern = "/var/log/edge/edge-httpdns/*.log" + fluentBitManagedMarker = "managed-by-edgeapi" + fluentBitRoleNode = "node" + fluentBitRoleDNS = "dns" + fluentBitRoleHTTPDNS = "httpdns" ) var errFluentBitLocalPackageNotFound = errors.New("fluent-bit local package not found") @@ -57,10 +59,11 @@ type fluentBitManagedMeta struct { } type fluentBitDesiredConfig struct { - Roles []string - ClickHouse *systemconfigs.ClickHouseSetting - HTTPPathPattern string - DNSPathPattern string + Roles []string + ClickHouse *systemconfigs.ClickHouseSetting + HTTPPathPattern string + DNSPathPattern string + HTTPDNSPathPattern string } // SetupFluentBit 安装并托管 Fluent Bit 配置(离线包 + 平台渲染配置)。 @@ -343,6 +346,8 @@ func mapNodeRole(role nodeconfigs.NodeRole) (string, error) { return fluentBitRoleNode, nil case nodeconfigs.NodeRoleDNS: return fluentBitRoleDNS, nil + case nodeconfigs.NodeRoleHTTPDNS: + return fluentBitRoleHTTPDNS, nil default: return "", fmt.Errorf("unsupported fluent-bit role '%s'", role) } @@ -352,7 +357,7 @@ func normalizeRoles(rawRoles []string) []string { roleSet := map[string]struct{}{} for _, role := range rawRoles { role = strings.ToLower(strings.TrimSpace(role)) - if role != fluentBitRoleNode && role != fluentBitRoleDNS { + if role != fluentBitRoleNode && role != fluentBitRoleDNS && role != fluentBitRoleHTTPDNS { continue } roleSet[role] = struct{}{} @@ -418,6 +423,7 @@ func (this *BaseInstaller) buildDesiredFluentBitConfig(roles []string) (*fluentB httpPathPattern := fluentBitHTTPPathPattern dnsPathPattern := fluentBitDNSPathPattern + httpdnsPathPattern := fluentBitHTTPDNSPathPattern publicPolicyPath, err := this.readPublicAccessLogPolicyPath() if err != nil { return nil, err @@ -427,13 +433,15 @@ func (this *BaseInstaller) buildDesiredFluentBitConfig(roles []string) (*fluentB pattern := strings.TrimRight(policyDir, "/") + "/*.log" httpPathPattern = pattern dnsPathPattern = pattern + httpdnsPathPattern = pattern } return &fluentBitDesiredConfig{ - Roles: normalizeRoles(roles), - ClickHouse: ch, - HTTPPathPattern: httpPathPattern, - DNSPathPattern: dnsPathPattern, + Roles: normalizeRoles(roles), + ClickHouse: ch, + HTTPPathPattern: httpPathPattern, + DNSPathPattern: dnsPathPattern, + HTTPDNSPathPattern: httpdnsPathPattern, }, nil } @@ -554,6 +562,7 @@ func renderManagedConfig(desired *fluentBitDesiredConfig) (string, error) { insertHTTP := url.QueryEscape(fmt.Sprintf("INSERT INTO %s.logs_ingest FORMAT JSONEachRow", desired.ClickHouse.Database)) insertDNS := url.QueryEscape(fmt.Sprintf("INSERT INTO %s.dns_logs_ingest FORMAT JSONEachRow", desired.ClickHouse.Database)) + insertHTTPDNS := url.QueryEscape(fmt.Sprintf("INSERT INTO %s.httpdns_access_logs_ingest FORMAT JSONEachRow", desired.ClickHouse.Database)) lines := []string{ "# " + fluentBitManagedMarker, @@ -602,6 +611,23 @@ func renderManagedConfig(desired *fluentBitDesiredConfig) (string, error) { ) } + if hasRole(desired.Roles, fluentBitRoleHTTPDNS) { + lines = append(lines, + "[INPUT]", + " Name tail", + " Path "+desired.HTTPDNSPathPattern, + " Tag app.httpdns.logs", + " Parser json", + " Refresh_Interval 2", + " Read_from_Head false", + " DB /var/lib/fluent-bit/httpdns-logs.db", + " storage.type filesystem", + " Mem_Buf_Limit 256MB", + " Skip_Long_Lines On", + "", + ) + } + if hasRole(desired.Roles, fluentBitRoleNode) { lines = append(lines, "[OUTPUT]", @@ -664,6 +690,37 @@ func renderManagedConfig(desired *fluentBitDesiredConfig) (string, error) { lines = append(lines, "") } + if hasRole(desired.Roles, fluentBitRoleHTTPDNS) { + lines = append(lines, + "[OUTPUT]", + " Name http", + " Match app.httpdns.logs", + " Host "+desired.ClickHouse.Host, + " Port "+strconv.Itoa(desired.ClickHouse.Port), + " URI /?query="+insertHTTPDNS, + " Format json_lines", + " http_user ${CH_USER}", + " http_passwd ${CH_PASSWORD}", + " json_date_key timestamp", + " json_date_format epoch", + " workers 2", + " net.keepalive On", + " Retry_Limit False", + ) + if useTLS { + lines = append(lines, " tls On") + if desired.ClickHouse.TLSSkipVerify { + lines = append(lines, " tls.verify Off") + } else { + lines = append(lines, " tls.verify On") + } + if strings.TrimSpace(desired.ClickHouse.TLSServerName) != "" { + lines = append(lines, " tls.vhost "+strings.TrimSpace(desired.ClickHouse.TLSServerName)) + } + } + lines = append(lines, "") + } + return strings.Join(lines, "\n"), nil } diff --git a/EdgeAPI/internal/installers/installer_httpdns_node.go b/EdgeAPI/internal/installers/installer_httpdns_node.go new file mode 100644 index 0000000..f93b600 --- /dev/null +++ b/EdgeAPI/internal/installers/installer_httpdns_node.go @@ -0,0 +1,236 @@ +package installers + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" +) + +type HTTPDNSNodeInstaller struct { + BaseInstaller +} + +func (i *HTTPDNSNodeInstaller) Install(dir string, params interface{}, installStatus *models.NodeInstallStatus) error { + if params == nil { + return errors.New("'params' required for node installation") + } + nodeParams, ok := params.(*NodeParams) + if !ok { + return errors.New("'params' should be *NodeParams") + } + err := nodeParams.Validate() + if err != nil { + return fmt.Errorf("params validation: %w", err) + } + + installRootDir, appDir := resolveHTTPDNSInstallPaths(dir) + + _, err = i.client.Stat(installRootDir) + if err != nil { + err = i.client.MkdirAll(installRootDir) + if err != nil { + installStatus.ErrorCode = "CREATE_ROOT_DIRECTORY_FAILED" + return fmt.Errorf("create directory '%s' failed: %w", installRootDir, err) + } + } + + env, err := i.InstallHelper(installRootDir, nodeconfigs.NodeRoleHTTPDNS) + if err != nil { + installStatus.ErrorCode = "INSTALL_HELPER_FAILED" + return err + } + + filePrefix := "edge-httpdns-" + env.OS + "-" + env.Arch + zipFile, err := i.LookupLatestInstallerForTarget(filePrefix, env) + if err != nil { + return err + } + if len(zipFile) == 0 { + return errors.New("can not find installer file for " + env.OS + "/" + env.Arch + ", expected '" + filePrefix + "-v*.zip' or distro-specific '" + filePrefix + "-{ubuntu22.04|amzn2023}-v*.zip'") + } + + targetZip, err := i.copyZipToRemote(installRootDir, zipFile) + if err != nil { + return err + } + + if !nodeParams.IsUpgrading { + _, stderr, testErr := i.client.Exec(env.HelperPath + " -cmd=test") + if testErr != nil { + return fmt.Errorf("test failed: %w", testErr) + } + if len(stderr) > 0 { + return errors.New("test failed: " + stderr) + } + } + + exePath := appDir + "/bin/edge-httpdns" + if nodeParams.IsUpgrading { + _, err = i.client.Stat(exePath) + if err == nil { + _, _, _ = i.client.Exec(exePath + " stop") + removeErr := i.client.Remove(exePath) + if removeErr != nil && removeErr != os.ErrNotExist { + return fmt.Errorf("remove old file failed: %w", removeErr) + } + } + } + + _, stderr, err := i.client.Exec(env.HelperPath + " -cmd=unzip -zip=\"" + targetZip + "\" -target=\"" + installRootDir + "\"") + if err != nil { + return err + } + if len(stderr) > 0 { + return errors.New("unzip installer failed: " + stderr) + } + + certFile := appDir + "/configs/tls/server.crt" + keyFile := appDir + "/configs/tls/server.key" + err = i.writeTLSCertificate(certFile, keyFile, nodeParams.TLSCertData, nodeParams.TLSKeyData) + if err != nil { + installStatus.ErrorCode = "WRITE_TLS_CERT_FAILED" + return err + } + + configFile := appDir + "/configs/api_httpdns.yaml" + if i.client.sudo { + _, _, _ = i.client.Exec("chown " + i.client.User() + " " + filepath.Dir(configFile)) + } + + listenAddr := strings.TrimSpace(nodeParams.HTTPDNSListenAddr) + if len(listenAddr) == 0 { + listenAddr = ":443" + } + + configData := []byte(`rpc.endpoints: [ ${endpoints} ] +nodeId: "${nodeId}" +secret: "${nodeSecret}" + +https.listenAddr: "${listenAddr}" +https.cert: "${certFile}" +https.key: "${keyFile}"`) + certFileClean := strings.ReplaceAll(certFile, "\\", "/") + keyFileClean := strings.ReplaceAll(keyFile, "\\", "/") + + configData = bytes.ReplaceAll(configData, []byte("${endpoints}"), []byte(nodeParams.QuoteEndpoints())) + configData = bytes.ReplaceAll(configData, []byte("${nodeId}"), []byte(nodeParams.NodeId)) + configData = bytes.ReplaceAll(configData, []byte("${nodeSecret}"), []byte(nodeParams.Secret)) + configData = bytes.ReplaceAll(configData, []byte("${listenAddr}"), []byte(listenAddr)) + configData = bytes.ReplaceAll(configData, []byte("${certFile}"), []byte(certFileClean)) + configData = bytes.ReplaceAll(configData, []byte("${keyFile}"), []byte(keyFileClean)) + + _, err = i.client.WriteFile(configFile, configData) + if err != nil { + return fmt.Errorf("write '%s': %w", configFile, err) + } + + err = i.SetupFluentBit(nodeconfigs.NodeRoleHTTPDNS) + if err != nil { + installStatus.ErrorCode = "SETUP_FLUENT_BIT_FAILED" + return fmt.Errorf("setup fluent-bit failed: %w", err) + } + + startCmdPrefix := "cd " + shQuote(appDir+"/configs") + " && ../bin/edge-httpdns " + + stdout, stderr, err := i.client.Exec(startCmdPrefix + "test") + if err != nil { + installStatus.ErrorCode = "TEST_FAILED" + return fmt.Errorf("test edge-httpdns failed: %w, stdout: %s, stderr: %s", err, stdout, stderr) + } + if len(stderr) > 0 { + if regexp.MustCompile(`(?i)rpc`).MatchString(stderr) || regexp.MustCompile(`(?i)rpc`).MatchString(stdout) { + installStatus.ErrorCode = "RPC_TEST_FAILED" + } + return errors.New("test edge-httpdns failed, stdout: " + stdout + ", stderr: " + stderr) + } + + stdout, stderr, err = i.client.Exec(startCmdPrefix + "start") + if err != nil { + return fmt.Errorf("start edge-httpdns failed: %w, stdout: %s, stderr: %s", err, stdout, stderr) + } + if len(stderr) > 0 { + return errors.New("start edge-httpdns failed, stdout: " + stdout + ", stderr: " + stderr) + } + + return nil +} + +func resolveHTTPDNSInstallPaths(rawDir string) (installRootDir string, appDir string) { + dir := strings.TrimSpace(rawDir) + dir = strings.TrimRight(dir, "/") + if len(dir) == 0 { + return rawDir, rawDir + "/edge-httpdns" + } + + if strings.HasSuffix(dir, "/edge-httpdns") { + root := strings.TrimSuffix(dir, "/edge-httpdns") + if len(root) == 0 { + root = "/" + } + return root, dir + } + + return dir, dir + "/edge-httpdns" +} + +func (i *HTTPDNSNodeInstaller) copyZipToRemote(dir string, zipFile string) (string, error) { + targetZip := "" + var firstCopyErr error + zipName := filepath.Base(zipFile) + for _, candidate := range []string{ + dir + "/" + zipName, + i.client.UserHome() + "/" + zipName, + "/tmp/" + zipName, + } { + err := i.client.Copy(zipFile, candidate, 0777) + if err != nil { + if firstCopyErr == nil { + firstCopyErr = err + } + continue + } + targetZip = candidate + firstCopyErr = nil + break + } + if firstCopyErr != nil { + return "", fmt.Errorf("upload httpdns file failed: %w", firstCopyErr) + } + return targetZip, nil +} + +func (i *HTTPDNSNodeInstaller) writeTLSCertificate(certFile string, keyFile string, certData []byte, keyData []byte) error { + if len(certData) == 0 || len(keyData) == 0 { + return errors.New("cluster tls certificate is empty") + } + certDir := filepath.Dir(certFile) + _, stderr, err := i.client.Exec("mkdir -p " + shQuote(certDir)) + if err != nil { + return fmt.Errorf("create tls directory failed: %w, stderr: %s", err, stderr) + } + if i.client.sudo { + _, _, _ = i.client.Exec("chown " + i.client.User() + " " + shQuote(certDir)) + } + + _, err = i.client.WriteFile(certFile, certData) + if err != nil { + return fmt.Errorf("write cert file failed: %w", err) + } + _, err = i.client.WriteFile(keyFile, keyData) + if err != nil { + return fmt.Errorf("write key file failed: %w", err) + } + + _, stderr, err = i.client.Exec("chmod 0644 " + shQuote(certFile) + " && chmod 0600 " + shQuote(keyFile)) + if err != nil { + return fmt.Errorf("chmod tls files failed: %w, stderr: %s", err, stderr) + } + return nil +} diff --git a/EdgeAPI/internal/installers/params_node.go b/EdgeAPI/internal/installers/params_node.go index b825150..ad12fc4 100644 --- a/EdgeAPI/internal/installers/params_node.go +++ b/EdgeAPI/internal/installers/params_node.go @@ -6,10 +6,13 @@ import ( ) type NodeParams struct { - Endpoints []string - NodeId string - Secret string - IsUpgrading bool // 是否为升级 + Endpoints []string + NodeId string + Secret string + TLSCertData []byte + TLSKeyData []byte + HTTPDNSListenAddr string + IsUpgrading bool // 是否为升级 } func (this *NodeParams) Validate() error { diff --git a/EdgeAPI/internal/installers/queue_httpdns_node.go b/EdgeAPI/internal/installers/queue_httpdns_node.go new file mode 100644 index 0000000..ba64cae --- /dev/null +++ b/EdgeAPI/internal/installers/queue_httpdns_node.go @@ -0,0 +1,416 @@ +package installers + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/goman" + "github.com/TeaOSLab/EdgeAPI/internal/utils" + "github.com/TeaOSLab/EdgeAPI/internal/utils/numberutils" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/sslconfigs" + "github.com/iwind/TeaGo/logs" +) + +var sharedHTTPDNSNodeQueue = NewHTTPDNSNodeQueue() + +type HTTPDNSNodeQueue struct{} + +func NewHTTPDNSNodeQueue() *HTTPDNSNodeQueue { + return &HTTPDNSNodeQueue{} +} + +func SharedHTTPDNSNodeQueue() *HTTPDNSNodeQueue { + return sharedHTTPDNSNodeQueue +} + +// InstallNodeProcess 鍦ㄧ嚎瀹夎 HTTPDNS 鑺傜偣娴佺▼鎺у埗 +func (q *HTTPDNSNodeQueue) InstallNodeProcess(nodeId int64, isUpgrading bool) error { + installStatus := models.NewNodeInstallStatus() + installStatus.IsRunning = true + installStatus.IsFinished = false + installStatus.IsOk = false + installStatus.Error = "" + installStatus.ErrorCode = "" + installStatus.UpdatedAt = time.Now().Unix() + + err := models.SharedHTTPDNSNodeDAO.UpdateNodeInstallStatus(nil, nodeId, installStatus) + if err != nil { + return err + } + + ticker := utils.NewTicker(3 * time.Second) + goman.New(func() { + for ticker.Wait() { + installStatus.UpdatedAt = time.Now().Unix() + updateErr := models.SharedHTTPDNSNodeDAO.UpdateNodeInstallStatus(nil, nodeId, installStatus) + if updateErr != nil { + logs.Println("[HTTPDNS_INSTALL]" + updateErr.Error()) + } + } + }) + defer ticker.Stop() + + err = q.InstallNode(nodeId, installStatus, isUpgrading) + + installStatus.IsRunning = false + installStatus.IsFinished = true + if err != nil { + installStatus.IsOk = false + installStatus.Error = err.Error() + } else { + installStatus.IsOk = true + } + installStatus.UpdatedAt = time.Now().Unix() + + updateErr := models.SharedHTTPDNSNodeDAO.UpdateNodeInstallStatus(nil, nodeId, installStatus) + if updateErr != nil { + return updateErr + } + + if installStatus.IsOk { + return models.SharedHTTPDNSNodeDAO.UpdateNodeIsInstalled(nil, nodeId, true) + } + return nil +} + +// InstallNode 鍦ㄧ嚎瀹夎 HTTPDNS 鑺傜偣 +func (q *HTTPDNSNodeQueue) InstallNode(nodeId int64, installStatus *models.NodeInstallStatus, isUpgrading bool) error { + node, err := models.SharedHTTPDNSNodeDAO.FindEnabledNode(nil, nodeId) + if err != nil { + return err + } + if node == nil { + return errors.New("can not find node, ID '" + numberutils.FormatInt64(nodeId) + "'") + } + cluster, err := models.SharedHTTPDNSClusterDAO.FindEnabledCluster(nil, int64(node.ClusterId)) + if err != nil { + return err + } + if cluster == nil { + return errors.New("can not find cluster") + } + + sshHost, sshPort, grantId, err := q.parseSSHInfo(node, installStatus) + if err != nil { + return err + } + + grant, err := models.SharedNodeGrantDAO.FindEnabledNodeGrant(nil, grantId) + if err != nil { + return err + } + if grant == nil { + installStatus.ErrorCode = "EMPTY_GRANT" + return errors.New("can not find user grant with id '" + numberutils.FormatInt64(grantId) + "'") + } + + apiNodes, err := models.SharedAPINodeDAO.FindAllEnabledAndOnAPINodes(nil) + if err != nil { + return err + } + if len(apiNodes) == 0 { + return errors.New("no available api nodes") + } + + apiEndpoints := make([]string, 0, 8) + for _, apiNode := range apiNodes { + addrConfigs, decodeErr := apiNode.DecodeAccessAddrs() + if decodeErr != nil { + return fmt.Errorf("decode api node access addresses failed: %w", decodeErr) + } + for _, addrConfig := range addrConfigs { + apiEndpoints = append(apiEndpoints, addrConfig.FullAddresses()...) + } + } + if len(apiEndpoints) == 0 { + return errors.New("no available api endpoints") + } + + tlsCertData, tlsKeyData, err := q.resolveClusterTLSCertPair(cluster) + if err != nil { + installStatus.ErrorCode = "EMPTY_TLS_CERT" + return err + } + httpdnsListenAddr, err := q.resolveClusterTLSListenAddr(cluster) + if err != nil { + installStatus.ErrorCode = "INVALID_TLS_LISTEN" + return err + } + + params := &NodeParams{ + Endpoints: apiEndpoints, + NodeId: node.UniqueId, + Secret: node.Secret, + TLSCertData: tlsCertData, + TLSKeyData: tlsKeyData, + HTTPDNSListenAddr: httpdnsListenAddr, + IsUpgrading: isUpgrading, + } + + installer := &HTTPDNSNodeInstaller{} + err = installer.Login(&Credentials{ + Host: sshHost, + Port: sshPort, + Username: grant.Username, + Password: grant.Password, + PrivateKey: grant.PrivateKey, + Passphrase: grant.Passphrase, + Method: grant.Method, + Sudo: grant.Su == 1, + }) + if err != nil { + installStatus.ErrorCode = "SSH_LOGIN_FAILED" + return err + } + defer func() { + _ = installer.Close() + }() + + installDir := node.InstallDir + if len(installDir) == 0 { + if cluster != nil && len(cluster.InstallDir) > 0 { + installDir = cluster.InstallDir + } + if len(installDir) == 0 { + installDir = installer.client.UserHome() + "/edge-httpdns" + } + } + + return installer.Install(installDir, params, installStatus) +} + +// StartNode 启动HTTPDNS节点 +func (q *HTTPDNSNodeQueue) StartNode(nodeId int64) error { + node, err := models.SharedHTTPDNSNodeDAO.FindEnabledNode(nil, nodeId) + if err != nil { + return err + } + if node == nil { + return errors.New("can not find node, ID '" + numberutils.FormatInt64(nodeId) + "'") + } + + // 登录信息 + login, err := models.SharedNodeLoginDAO.FindEnabledNodeLoginWithNodeId(nil, nodeconfigs.NodeRoleHTTPDNS, nodeId) + if err != nil { + return err + } + if login == nil { + return newGrantError("can not find node login information") + } + loginParams, err := login.DecodeSSHParams() + if err != nil { + return newGrantError(err.Error()) + } + if len(strings.TrimSpace(loginParams.Host)) == 0 { + return newGrantError("ssh host should not be empty") + } + if loginParams.Port <= 0 { + loginParams.Port = 22 + } + if loginParams.GrantId <= 0 { + return newGrantError("can not find node grant") + } + + grant, err := models.SharedNodeGrantDAO.FindEnabledNodeGrant(nil, loginParams.GrantId) + if err != nil { + return err + } + if grant == nil { + return newGrantError("can not find user grant with id '" + numberutils.FormatInt64(loginParams.GrantId) + "'") + } + + installer := &HTTPDNSNodeInstaller{} + err = installer.Login(&Credentials{ + Host: strings.TrimSpace(loginParams.Host), + Port: loginParams.Port, + Username: grant.Username, + Password: grant.Password, + PrivateKey: grant.PrivateKey, + Passphrase: grant.Passphrase, + Method: grant.Method, + Sudo: grant.Su == 1, + }) + if err != nil { + return err + } + defer func() { + _ = installer.Close() + }() + + installDir := strings.TrimSpace(node.InstallDir) + if len(installDir) == 0 { + cluster, err := models.SharedHTTPDNSClusterDAO.FindEnabledCluster(nil, int64(node.ClusterId)) + if err != nil { + return err + } + if cluster == nil { + return errors.New("can not find cluster, ID '" + numberutils.FormatInt64(int64(node.ClusterId)) + "'") + } + installDir = strings.TrimSpace(cluster.InstallDir) + if len(installDir) == 0 { + installDir = installer.client.UserHome() + "/edge-httpdns" + } + } + _, appDir := resolveHTTPDNSInstallPaths(installDir) + exeFile := appDir + "/bin/edge-httpdns" + + _, err = installer.client.Stat(exeFile) + if err != nil { + return errors.New("httpdns node is not installed correctly, can not find executable file: " + exeFile) + } + + // 先尝试 systemd 拉起 + _, _, _ = installer.client.Exec("/usr/bin/systemctl start edge-httpdns") + + _, stderr, err := installer.client.Exec(exeFile + " start") + if err != nil { + return fmt.Errorf("start failed: %w", err) + } + if len(strings.TrimSpace(stderr)) > 0 { + return errors.New("start failed: " + strings.TrimSpace(stderr)) + } + + return nil +} + +func (q *HTTPDNSNodeQueue) resolveClusterTLSCertPair(cluster *models.HTTPDNSCluster) ([]byte, []byte, error) { + if cluster == nil { + return nil, nil, errors.New("cluster not found") + } + if len(cluster.TLSPolicy) == 0 { + return nil, nil, errors.New("cluster tls policy is empty") + } + + tlsConfig := map[string]json.RawMessage{} + if err := json.Unmarshal(cluster.TLSPolicy, &tlsConfig); err != nil { + return nil, nil, fmt.Errorf("decode cluster tls policy failed: %w", err) + } + + sslPolicyData := tlsConfig["sslPolicy"] + if len(sslPolicyData) == 0 { + // Compatible with old data where TLSPolicy stores SSLPolicy directly. + sslPolicyData = json.RawMessage(cluster.TLSPolicy) + } + sslPolicy := &sslconfigs.SSLPolicy{} + if err := json.Unmarshal(sslPolicyData, sslPolicy); err != nil { + return nil, nil, fmt.Errorf("decode ssl policy failed: %w", err) + } + + for _, cert := range sslPolicy.Certs { + if cert == nil { + continue + } + if len(cert.CertData) > 0 && len(cert.KeyData) > 0 { + return cert.CertData, cert.KeyData, nil + } + } + + for _, certRef := range sslPolicy.CertRefs { + if certRef == nil || certRef.CertId <= 0 { + continue + } + certConfig, err := models.SharedSSLCertDAO.ComposeCertConfig(nil, certRef.CertId, false, nil, nil) + if err != nil { + return nil, nil, fmt.Errorf("load ssl cert %d failed: %w", certRef.CertId, err) + } + if certConfig == nil { + continue + } + if len(certConfig.CertData) > 0 && len(certConfig.KeyData) > 0 { + return certConfig.CertData, certConfig.KeyData, nil + } + } + + if sslPolicy.Id > 0 { + policyConfig, err := models.SharedSSLPolicyDAO.ComposePolicyConfig(nil, sslPolicy.Id, false, nil, nil) + if err != nil { + return nil, nil, fmt.Errorf("load ssl policy %d failed: %w", sslPolicy.Id, err) + } + if policyConfig != nil { + for _, cert := range policyConfig.Certs { + if cert == nil { + continue + } + if len(cert.CertData) > 0 && len(cert.KeyData) > 0 { + return cert.CertData, cert.KeyData, nil + } + } + } + } + + return nil, nil, errors.New("cluster tls certificate is not configured") +} + +func (q *HTTPDNSNodeQueue) resolveClusterTLSListenAddr(cluster *models.HTTPDNSCluster) (string, error) { + const defaultListenAddr = ":443" + + if cluster == nil || len(cluster.TLSPolicy) == 0 { + return defaultListenAddr, nil + } + + tlsConfig, err := serverconfigs.NewTLSProtocolConfigFromJSON(cluster.TLSPolicy) + if err != nil { + return "", fmt.Errorf("decode cluster tls listen failed: %w", err) + } + + for _, listen := range tlsConfig.Listen { + if listen == nil { + continue + } + + if err := listen.Init(); err != nil { + return "", fmt.Errorf("invalid cluster tls listen address '%s': %w", listen.PortRange, err) + } + if listen.MinPort <= 0 { + continue + } + + host := strings.TrimSpace(listen.Host) + return net.JoinHostPort(host, strconv.Itoa(listen.MinPort)), nil + } + + return defaultListenAddr, nil +} + +func (q *HTTPDNSNodeQueue) parseSSHInfo(node *models.HTTPDNSNode, installStatus *models.NodeInstallStatus) (string, int, int64, error) { + if node == nil { + return "", 0, 0, errors.New("node should not be nil") + } + + login, err := models.SharedNodeLoginDAO.FindEnabledNodeLoginWithNodeId(nil, nodeconfigs.NodeRoleHTTPDNS, int64(node.Id)) + if err != nil { + return "", 0, 0, err + } + if login == nil { + installStatus.ErrorCode = "EMPTY_SSH" + return "", 0, 0, errors.New("ssh login not found for node '" + numberutils.FormatInt64(int64(node.Id)) + "'") + } + + sshParams, err := login.DecodeSSHParams() + if err != nil { + installStatus.ErrorCode = "EMPTY_SSH" + return "", 0, 0, err + } + + if len(sshParams.Host) == 0 { + installStatus.ErrorCode = "EMPTY_SSH_HOST" + return "", 0, 0, errors.New("ssh host should not be empty") + } + if sshParams.Port <= 0 { + sshParams.Port = 22 + } + if sshParams.GrantId <= 0 { + installStatus.ErrorCode = "EMPTY_GRANT" + return "", 0, 0, errors.New("grant id should not be empty") + } + + return sshParams.Host, sshParams.Port, sshParams.GrantId, nil +} diff --git a/EdgeAPI/internal/installers/upgrade_queue.go b/EdgeAPI/internal/installers/upgrade_queue.go new file mode 100644 index 0000000..ca08654 --- /dev/null +++ b/EdgeAPI/internal/installers/upgrade_queue.go @@ -0,0 +1,25 @@ +package installers + +// UpgradeQueue 升级队列,控制并发数 +type UpgradeQueue struct { + sem chan struct{} +} + +// SharedUpgradeQueue 全局升级队列,最多5个并发 +var SharedUpgradeQueue = NewUpgradeQueue(5) + +// NewUpgradeQueue 创建升级队列 +func NewUpgradeQueue(maxConcurrent int) *UpgradeQueue { + return &UpgradeQueue{ + sem: make(chan struct{}, maxConcurrent), + } +} + +// SubmitNodeUpgrade 提交节点升级任务(异步执行,超过并发限制自动排队) +func (q *UpgradeQueue) SubmitNodeUpgrade(nodeId int64, upgradeFunc func(int64) error) { + go func() { + q.sem <- struct{}{} + defer func() { <-q.sem }() + _ = upgradeFunc(nodeId) + }() +} diff --git a/EdgeAPI/internal/nodes/api_node.go b/EdgeAPI/internal/nodes/api_node.go index c9ea2fc..e6b05e3 100644 --- a/EdgeAPI/internal/nodes/api_node.go +++ b/EdgeAPI/internal/nodes/api_node.go @@ -144,6 +144,17 @@ func (this *APINode) Start() { this.processTableNames() dbs.NotifyReady() + // 自动确保 ClickHouse 日志表存在(不阻断主流程) + this.setProgress("CLICKHOUSE", "正在检查 ClickHouse 日志表") + logs.Println("[API_NODE]ensuring clickhouse tables ...") + err = setup.EnsureClickHouseTables() + if err != nil { + logs.Println("[API_NODE]WARNING: ensure clickhouse tables failed: " + err.Error()) + remotelogs.Error("API_NODE", "ensure clickhouse tables failed: "+err.Error()) + } else { + logs.Println("[API_NODE]ensure clickhouse tables done") + } + // 设置时区 this.setProgress("TIMEZONE", "正在设置时区") this.setupTimeZone() diff --git a/EdgeAPI/internal/nodes/api_node_services.go b/EdgeAPI/internal/nodes/api_node_services.go index be28ac3..b7df91a 100644 --- a/EdgeAPI/internal/nodes/api_node_services.go +++ b/EdgeAPI/internal/nodes/api_node_services.go @@ -5,6 +5,7 @@ package nodes import ( "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" "github.com/TeaOSLab/EdgeAPI/internal/rpc/services/clients" + httpdnsservices "github.com/TeaOSLab/EdgeAPI/internal/rpc/services/httpdns" "github.com/TeaOSLab/EdgeAPI/internal/rpc/services/users" "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" "google.golang.org/grpc" @@ -425,6 +426,46 @@ func (this *APINode) registerServices(server *grpc.Server) { pb.RegisterDNSTaskServiceServer(server, instance) this.rest(instance) } + { + var instance = this.serviceInstance(&httpdnsservices.HTTPDNSClusterService{}).(*httpdnsservices.HTTPDNSClusterService) + pb.RegisterHTTPDNSClusterServiceServer(server, instance) + this.rest(instance) + } + { + var instance = this.serviceInstance(&httpdnsservices.HTTPDNSNodeService{}).(*httpdnsservices.HTTPDNSNodeService) + pb.RegisterHTTPDNSNodeServiceServer(server, instance) + this.rest(instance) + } + { + var instance = this.serviceInstance(&httpdnsservices.HTTPDNSAppService{}).(*httpdnsservices.HTTPDNSAppService) + pb.RegisterHTTPDNSAppServiceServer(server, instance) + this.rest(instance) + } + { + var instance = this.serviceInstance(&httpdnsservices.HTTPDNSDomainService{}).(*httpdnsservices.HTTPDNSDomainService) + pb.RegisterHTTPDNSDomainServiceServer(server, instance) + this.rest(instance) + } + { + var instance = this.serviceInstance(&httpdnsservices.HTTPDNSRuleService{}).(*httpdnsservices.HTTPDNSRuleService) + pb.RegisterHTTPDNSRuleServiceServer(server, instance) + this.rest(instance) + } + { + var instance = this.serviceInstance(&httpdnsservices.HTTPDNSAccessLogService{}).(*httpdnsservices.HTTPDNSAccessLogService) + pb.RegisterHTTPDNSAccessLogServiceServer(server, instance) + this.rest(instance) + } + { + var instance = this.serviceInstance(&httpdnsservices.HTTPDNSRuntimeLogService{}).(*httpdnsservices.HTTPDNSRuntimeLogService) + pb.RegisterHTTPDNSRuntimeLogServiceServer(server, instance) + this.rest(instance) + } + { + var instance = this.serviceInstance(&httpdnsservices.HTTPDNSSandboxService{}).(*httpdnsservices.HTTPDNSSandboxService) + pb.RegisterHTTPDNSSandboxServiceServer(server, instance) + this.rest(instance) + } { var instance = this.serviceInstance(&services.NodeClusterFirewallActionService{}).(*services.NodeClusterFirewallActionService) pb.RegisterNodeClusterFirewallActionServiceServer(server, instance) diff --git a/EdgeAPI/internal/rpc/services/httpdns/converters.go b/EdgeAPI/internal/rpc/services/httpdns/converters.go new file mode 100644 index 0000000..d8c70c9 --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/converters.go @@ -0,0 +1,220 @@ +package httpdns + +import ( + "encoding/json" + "log" + + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/sslconfigs" + "github.com/iwind/TeaGo/dbs" +) + +func toPBCluster(cluster *models.HTTPDNSCluster) *pb.HTTPDNSCluster { + if cluster == nil { + return nil + } + return &pb.HTTPDNSCluster{ + Id: int64(cluster.Id), + IsOn: cluster.IsOn, + IsDefault: cluster.IsDefault, + Name: cluster.Name, + ServiceDomain: cluster.ServiceDomain, + DefaultTTL: cluster.DefaultTTL, + FallbackTimeoutMs: cluster.FallbackTimeoutMs, + InstallDir: cluster.InstallDir, + TlsPolicyJSON: cluster.TLSPolicy, + CreatedAt: int64(cluster.CreatedAt), + UpdatedAt: int64(cluster.UpdatedAt), + AutoRemoteStart: cluster.AutoRemoteStart, + AccessLogIsOn: cluster.AccessLogIsOn, + TimeZone: cluster.TimeZone, + } +} + +// toPBClusterWithResolvedCerts 转换集群并解析证书引用为实际 PEM 数据 +// 供节点调用的 RPC 使用,确保节点能拿到完整的证书内容 +func toPBClusterWithResolvedCerts(tx *dbs.Tx, cluster *models.HTTPDNSCluster) *pb.HTTPDNSCluster { + pbCluster := toPBCluster(cluster) + if pbCluster == nil { + return nil + } + resolved := resolveTLSPolicyCerts(tx, cluster.TLSPolicy) + if resolved != nil { + pbCluster.TlsPolicyJSON = resolved + } + return pbCluster +} + +// resolveTLSPolicyCerts 将 tlsPolicyJSON 中的 certRefs 解析为带实际 PEM 数据的 certs +func resolveTLSPolicyCerts(tx *dbs.Tx, tlsPolicyJSON []byte) []byte { + if len(tlsPolicyJSON) == 0 { + return nil + } + + // 解析外层结构: {"listen": [...], "sslPolicy": {...}} + var tlsConfig map[string]json.RawMessage + if err := json.Unmarshal(tlsPolicyJSON, &tlsConfig); err != nil { + return nil + } + + sslPolicyData, ok := tlsConfig["sslPolicy"] + if !ok || len(sslPolicyData) == 0 { + return nil + } + + var sslPolicy sslconfigs.SSLPolicy + if err := json.Unmarshal(sslPolicyData, &sslPolicy); err != nil { + return nil + } + + // 检查 certs 是否已经有实际数据 + for _, cert := range sslPolicy.Certs { + if cert != nil && len(cert.CertData) > 128 && len(cert.KeyData) > 128 { + return nil // 已有完整 PEM 数据,无需处理 + } + } + + // 从 certRefs 解析实际证书数据 + if len(sslPolicy.CertRefs) == 0 { + return nil + } + + var resolvedCerts []*sslconfigs.SSLCertConfig + for _, ref := range sslPolicy.CertRefs { + if ref == nil || ref.CertId <= 0 { + continue + } + certConfig, err := models.SharedSSLCertDAO.ComposeCertConfig(tx, ref.CertId, false, nil, nil) + if err != nil { + log.Println("[HTTPDNS]resolve cert", ref.CertId, "failed:", err.Error()) + continue + } + if certConfig == nil || len(certConfig.CertData) == 0 || len(certConfig.KeyData) == 0 { + continue + } + resolvedCerts = append(resolvedCerts, certConfig) + } + + if len(resolvedCerts) == 0 { + return nil + } + + // 把解析后的证书写回 sslPolicy.Certs + sslPolicy.Certs = resolvedCerts + + newPolicyData, err := json.Marshal(&sslPolicy) + if err != nil { + return nil + } + tlsConfig["sslPolicy"] = newPolicyData + + result, err := json.Marshal(tlsConfig) + if err != nil { + return nil + } + return result +} + +func toPBNode(node *models.HTTPDNSNode) *pb.HTTPDNSNode { + if node == nil { + return nil + } + return &pb.HTTPDNSNode{ + Id: int64(node.Id), + ClusterId: int64(node.ClusterId), + Name: node.Name, + IsOn: node.IsOn, + IsUp: node.IsUp, + IsInstalled: node.IsInstalled, + IsActive: node.IsActive, + UniqueId: node.UniqueId, + Secret: node.Secret, + InstallDir: node.InstallDir, + StatusJSON: node.Status, + InstallStatusJSON: node.InstallStatus, + CreatedAt: int64(node.CreatedAt), + UpdatedAt: int64(node.UpdatedAt), + } +} + +func toPBApp(app *models.HTTPDNSApp, secret *models.HTTPDNSAppSecret) *pb.HTTPDNSApp { + if app == nil { + return nil + } + var signEnabled bool + var signSecret string + var signUpdatedAt int64 + if secret != nil { + signEnabled = secret.SignEnabled + signSecret = secret.SignSecret + signUpdatedAt = int64(secret.SignUpdatedAt) + } + // 构建 clusterIdsJSON + clusterIds := models.SharedHTTPDNSAppDAO.ReadAppClusterIds(app) + clusterIdsJSON, _ := json.Marshal(clusterIds) + + return &pb.HTTPDNSApp{ + Id: int64(app.Id), + Name: app.Name, + AppId: app.AppId, + IsOn: app.IsOn, + SniMode: app.SNIMode, + SignEnabled: signEnabled, + SignSecret: signSecret, + SignUpdatedAt: signUpdatedAt, + CreatedAt: int64(app.CreatedAt), + UpdatedAt: int64(app.UpdatedAt), + UserId: int64(app.UserId), + ClusterIdsJSON: clusterIdsJSON, + } +} + +func toPBDomain(domain *models.HTTPDNSDomain, ruleCount int64) *pb.HTTPDNSDomain { + if domain == nil { + return nil + } + return &pb.HTTPDNSDomain{ + Id: int64(domain.Id), + AppId: int64(domain.AppId), + Domain: domain.Domain, + IsOn: domain.IsOn, + CreatedAt: int64(domain.CreatedAt), + UpdatedAt: int64(domain.UpdatedAt), + RuleCount: ruleCount, + } +} + +func toPBRule(rule *models.HTTPDNSCustomRule, records []*models.HTTPDNSCustomRuleRecord) *pb.HTTPDNSCustomRule { + if rule == nil { + return nil + } + var pbRecords []*pb.HTTPDNSRuleRecord + for _, record := range records { + pbRecords = append(pbRecords, &pb.HTTPDNSRuleRecord{ + Id: int64(record.Id), + RuleId: int64(record.RuleId), + RecordType: record.RecordType, + RecordValue: record.RecordValue, + Weight: record.Weight, + Sort: record.Sort, + }) + } + return &pb.HTTPDNSCustomRule{ + Id: int64(rule.Id), + AppId: int64(rule.AppId), + DomainId: int64(rule.DomainId), + RuleName: rule.RuleName, + LineScope: rule.LineScope, + LineCarrier: rule.LineCarrier, + LineRegion: rule.LineRegion, + LineProvince: rule.LineProvince, + LineContinent: rule.LineContinent, + LineCountry: rule.LineCountry, + Ttl: rule.TTL, + IsOn: rule.IsOn, + Priority: rule.Priority, + UpdatedAt: int64(rule.UpdatedAt), + Records: pbRecords, + } +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_access_log.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_access_log.go new file mode 100644 index 0000000..be0e527 --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_access_log.go @@ -0,0 +1,287 @@ +package httpdns + +import ( + "context" + "log" + "strconv" + "strings" + "time" + + "github.com/TeaOSLab/EdgeAPI/internal/clickhouse" + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + timeutil "github.com/iwind/TeaGo/utils/time" +) + +type HTTPDNSAccessLogService struct { + services.BaseService + pb.UnimplementedHTTPDNSAccessLogServiceServer +} + +func (s *HTTPDNSAccessLogService) CreateHTTPDNSAccessLogs(ctx context.Context, req *pb.CreateHTTPDNSAccessLogsRequest) (*pb.CreateHTTPDNSAccessLogsResponse, error) { + nodeIdInContext, err := s.ValidateHTTPDNSNode(ctx) + if err != nil { + _, err = s.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + } + + if len(req.GetLogs()) == 0 { + return &pb.CreateHTTPDNSAccessLogsResponse{}, nil + } + + mysqlLogs := make([]*models.HTTPDNSAccessLog, 0, len(req.GetLogs())) + chLogs := make([]*pb.HTTPDNSAccessLog, 0, len(req.GetLogs())) + seen := map[string]struct{}{} + for _, item := range req.GetLogs() { + if item == nil { + continue + } + nodeId := item.GetNodeId() + // When called by HTTPDNS node, trust node id parsed from RPC token. + if nodeIdInContext > 0 { + nodeId = nodeIdInContext + } + clusterId := item.GetClusterId() + if clusterId <= 0 && nodeId > 0 { + clusterId, _ = models.SharedHTTPDNSNodeDAO.FindNodeClusterId(s.NullTx(), nodeId) + } + + key := item.GetRequestId() + "#" + strconv.FormatInt(nodeId, 10) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + createdAt := item.GetCreatedAt() + if createdAt <= 0 { + createdAt = time.Now().Unix() + } + day := item.GetDay() + if len(day) == 0 { + day = timeutil.Format("Ymd") + } + + mysqlLogs = append(mysqlLogs, &models.HTTPDNSAccessLog{ + RequestId: item.GetRequestId(), + ClusterId: uint32(clusterId), + NodeId: uint32(nodeId), + AppId: item.GetAppId(), + AppName: item.GetAppName(), + Domain: item.GetDomain(), + QType: item.GetQtype(), + ClientIP: item.GetClientIP(), + ClientRegion: item.GetClientRegion(), + Carrier: item.GetCarrier(), + SDKVersion: item.GetSdkVersion(), + OS: item.GetOs(), + ResultIPs: item.GetResultIPs(), + Status: item.GetStatus(), + ErrorCode: item.GetErrorCode(), + CostMs: item.GetCostMs(), + CreatedAt: uint64(createdAt), + Day: day, + Summary: item.GetSummary(), + }) + + chLogs = append(chLogs, &pb.HTTPDNSAccessLog{ + RequestId: item.GetRequestId(), + ClusterId: clusterId, + NodeId: nodeId, + AppId: item.GetAppId(), + AppName: item.GetAppName(), + Domain: item.GetDomain(), + Qtype: item.GetQtype(), + ClientIP: item.GetClientIP(), + ClientRegion: item.GetClientRegion(), + Carrier: item.GetCarrier(), + SdkVersion: item.GetSdkVersion(), + Os: item.GetOs(), + ResultIPs: item.GetResultIPs(), + Status: item.GetStatus(), + ErrorCode: item.GetErrorCode(), + CostMs: item.GetCostMs(), + CreatedAt: createdAt, + Day: day, + Summary: item.GetSummary(), + }) + } + + if s.canWriteHTTPDNSAccessLogsToMySQL() { + for _, item := range mysqlLogs { + err = models.SharedHTTPDNSAccessLogDAO.CreateLog(s.NullTx(), item) + if err != nil { + if models.CheckSQLDuplicateErr(err) { + continue + } + return nil, err + } + } + } + + store := clickhouse.NewHTTPDNSAccessLogsStore() + if s.canWriteHTTPDNSAccessLogsToClickHouse() && store.Client().IsConfigured() && len(chLogs) > 0 { + err = store.Insert(ctx, chLogs) + if err != nil { + log.Println("[HTTPDNS_ACCESS_LOG]write clickhouse failed, keep mysql success:", err.Error()) + } + } + + return &pb.CreateHTTPDNSAccessLogsResponse{}, nil +} + +func (s *HTTPDNSAccessLogService) ListHTTPDNSAccessLogs(ctx context.Context, req *pb.ListHTTPDNSAccessLogsRequest) (*pb.ListHTTPDNSAccessLogsResponse, error) { + _, userId, err := s.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + + allowedAppIds := []string(nil) + if userId > 0 { + if len(strings.TrimSpace(req.GetAppId())) > 0 { + app, err := ensureAppAccessByAppId(s.NullTx(), req.GetAppId(), userId) + if err != nil { + return nil, err + } + if app == nil { + return &pb.ListHTTPDNSAccessLogsResponse{ + Logs: []*pb.HTTPDNSAccessLog{}, + Total: 0, + }, nil + } + } else { + allowedAppIds, err = models.SharedHTTPDNSAppDAO.ListEnabledAppIdsWithUser(s.NullTx(), userId) + if err != nil { + return nil, err + } + if len(allowedAppIds) == 0 { + return &pb.ListHTTPDNSAccessLogsResponse{ + Logs: []*pb.HTTPDNSAccessLog{}, + Total: 0, + }, nil + } + } + } + + store := clickhouse.NewHTTPDNSAccessLogsStore() + canReadFromClickHouse := s.shouldReadHTTPDNSAccessLogsFromClickHouse() && store.Client().IsConfigured() + canReadFromMySQL := s.shouldReadHTTPDNSAccessLogsFromMySQL() + if canReadFromClickHouse { + resp, listErr := s.listFromClickHouse(ctx, store, req, allowedAppIds) + if listErr == nil { + return resp, nil + } + log.Println("[HTTPDNS_ACCESS_LOG]read clickhouse failed, fallback mysql:", listErr.Error()) + if !canReadFromMySQL { + return nil, listErr + } + } + + if !canReadFromMySQL { + return &pb.ListHTTPDNSAccessLogsResponse{ + Logs: []*pb.HTTPDNSAccessLog{}, + Total: 0, + }, nil + } + + total, err := models.SharedHTTPDNSAccessLogDAO.CountLogsWithAppIds(s.NullTx(), req.GetDay(), req.GetClusterId(), req.GetNodeId(), req.GetAppId(), allowedAppIds, req.GetDomain(), req.GetStatus(), req.GetKeyword()) + if err != nil { + return nil, err + } + logs, err := models.SharedHTTPDNSAccessLogDAO.ListLogsWithAppIds(s.NullTx(), req.GetDay(), req.GetClusterId(), req.GetNodeId(), req.GetAppId(), allowedAppIds, req.GetDomain(), req.GetStatus(), req.GetKeyword(), req.GetOffset(), req.GetSize()) + if err != nil { + return nil, err + } + + result := make([]*pb.HTTPDNSAccessLog, 0, len(logs)) + for _, item := range logs { + if item == nil { + continue + } + + clusterName, _ := models.SharedHTTPDNSClusterDAO.FindEnabledClusterName(s.NullTx(), int64(item.ClusterId)) + nodeName := "" + node, _ := models.SharedHTTPDNSNodeDAO.FindEnabledNode(s.NullTx(), int64(item.NodeId)) + if node != nil { + nodeName = node.Name + } + + result = append(result, &pb.HTTPDNSAccessLog{ + Id: int64(item.Id), + RequestId: item.RequestId, + ClusterId: int64(item.ClusterId), + NodeId: int64(item.NodeId), + AppId: item.AppId, + AppName: item.AppName, + Domain: item.Domain, + Qtype: item.QType, + ClientIP: item.ClientIP, + ClientRegion: item.ClientRegion, + Carrier: item.Carrier, + SdkVersion: item.SDKVersion, + Os: item.OS, + ResultIPs: item.ResultIPs, + Status: item.Status, + ErrorCode: item.ErrorCode, + CostMs: item.CostMs, + CreatedAt: int64(item.CreatedAt), + Day: item.Day, + Summary: item.Summary, + NodeName: nodeName, + ClusterName: clusterName, + }) + } + + return &pb.ListHTTPDNSAccessLogsResponse{ + Logs: result, + Total: total, + }, nil +} + +func (s *HTTPDNSAccessLogService) listFromClickHouse(ctx context.Context, store *clickhouse.HTTPDNSAccessLogsStore, req *pb.ListHTTPDNSAccessLogsRequest, allowedAppIds []string) (*pb.ListHTTPDNSAccessLogsResponse, error) { + filter := clickhouse.HTTPDNSAccessLogListFilter{ + Day: req.GetDay(), + ClusterId: req.GetClusterId(), + NodeId: req.GetNodeId(), + AppId: req.GetAppId(), + AppIds: allowedAppIds, + Domain: req.GetDomain(), + Status: req.GetStatus(), + Keyword: req.GetKeyword(), + Offset: req.GetOffset(), + Size: req.GetSize(), + } + + total, err := store.Count(ctx, filter) + if err != nil { + return nil, err + } + rows, err := store.List(ctx, filter) + if err != nil { + return nil, err + } + + result := make([]*pb.HTTPDNSAccessLog, 0, len(rows)) + for _, row := range rows { + item := clickhouse.HTTPDNSRowToPB(row) + if item == nil { + continue + } + clusterName, _ := models.SharedHTTPDNSClusterDAO.FindEnabledClusterName(s.NullTx(), item.GetClusterId()) + nodeName := "" + node, _ := models.SharedHTTPDNSNodeDAO.FindEnabledNode(s.NullTx(), item.GetNodeId()) + if node != nil { + nodeName = node.Name + } + item.ClusterName = clusterName + item.NodeName = nodeName + result = append(result, item) + } + + return &pb.ListHTTPDNSAccessLogsResponse{ + Logs: result, + Total: total, + }, nil +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_access_log_ext.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_access_log_ext.go new file mode 100644 index 0000000..8a2c01d --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_access_log_ext.go @@ -0,0 +1,19 @@ +//go:build !plus + +package httpdns + +func (s *HTTPDNSAccessLogService) canWriteHTTPDNSAccessLogsToMySQL() bool { + return true +} + +func (s *HTTPDNSAccessLogService) canWriteHTTPDNSAccessLogsToClickHouse() bool { + return true +} + +func (s *HTTPDNSAccessLogService) shouldReadHTTPDNSAccessLogsFromClickHouse() bool { + return true +} + +func (s *HTTPDNSAccessLogService) shouldReadHTTPDNSAccessLogsFromMySQL() bool { + return true +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_access_log_ext_plus.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_access_log_ext_plus.go new file mode 100644 index 0000000..cda9276 --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_access_log_ext_plus.go @@ -0,0 +1,124 @@ +//go:build plus + +package httpdns + +import ( + "log" + "sync" + "time" + + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs" +) + +var ( + httpDNSAccessLogWriteTargetsLocker sync.RWMutex + httpDNSAccessLogWriteTargetsCache = &serverconfigs.AccessLogWriteTargets{ + File: true, + MySQL: true, + ClickHouse: false, + } + httpDNSAccessLogWriteTargetsExpireAt int64 +) + +const httpDNSAccessLogWriteTargetsCacheTTL = 10 * time.Second + +func (s *HTTPDNSAccessLogService) canWriteHTTPDNSAccessLogsToMySQL() bool { + targets := s.readHTTPDNSAccessLogWriteTargets() + if targets == nil { + return true + } + return targets.MySQL +} + +func (s *HTTPDNSAccessLogService) canWriteHTTPDNSAccessLogsToClickHouse() bool { + targets := s.readHTTPDNSAccessLogWriteTargets() + if targets == nil { + return false + } + return targets.ClickHouse +} + +func (s *HTTPDNSAccessLogService) shouldReadHTTPDNSAccessLogsFromClickHouse() bool { + targets := s.readHTTPDNSAccessLogWriteTargets() + if targets == nil { + return false + } + return targets.ClickHouse +} + +func (s *HTTPDNSAccessLogService) shouldReadHTTPDNSAccessLogsFromMySQL() bool { + targets := s.readHTTPDNSAccessLogWriteTargets() + if targets == nil { + return true + } + return targets.MySQL +} + +func (s *HTTPDNSAccessLogService) readHTTPDNSAccessLogWriteTargets() *serverconfigs.AccessLogWriteTargets { + now := time.Now().Unix() + + httpDNSAccessLogWriteTargetsLocker.RLock() + if now < httpDNSAccessLogWriteTargetsExpireAt && httpDNSAccessLogWriteTargetsCache != nil { + targets := *httpDNSAccessLogWriteTargetsCache + httpDNSAccessLogWriteTargetsLocker.RUnlock() + return &targets + } + httpDNSAccessLogWriteTargetsLocker.RUnlock() + + httpDNSAccessLogWriteTargetsLocker.Lock() + defer httpDNSAccessLogWriteTargetsLocker.Unlock() + + // double-check + now = time.Now().Unix() + if now < httpDNSAccessLogWriteTargetsExpireAt && httpDNSAccessLogWriteTargetsCache != nil { + targets := *httpDNSAccessLogWriteTargetsCache + return &targets + } + + targets := s.loadHTTPDNSAccessLogWriteTargetsFromPolicy() + if targets == nil { + targets = &serverconfigs.AccessLogWriteTargets{ + File: true, + MySQL: true, + ClickHouse: false, + } + } + + httpDNSAccessLogWriteTargetsCache = targets + httpDNSAccessLogWriteTargetsExpireAt = time.Now().Add(httpDNSAccessLogWriteTargetsCacheTTL).Unix() + + copyTargets := *targets + return ©Targets +} + +func (s *HTTPDNSAccessLogService) loadHTTPDNSAccessLogWriteTargetsFromPolicy() *serverconfigs.AccessLogWriteTargets { + tx := s.NullTx() + publicPolicyId, err := models.SharedHTTPAccessLogPolicyDAO.FindCurrentPublicPolicyId(tx) + if err != nil { + log.Println("[HTTPDNS_ACCESS_LOG]load public access log policy failed:", err.Error()) + return nil + } + if publicPolicyId <= 0 { + return &serverconfigs.AccessLogWriteTargets{ + File: true, + MySQL: true, + ClickHouse: false, + } + } + + policy, err := models.SharedHTTPAccessLogPolicyDAO.FindEnabledHTTPAccessLogPolicy(tx, publicPolicyId) + if err != nil { + log.Println("[HTTPDNS_ACCESS_LOG]load access log policy detail failed:", err.Error()) + return nil + } + if policy == nil { + return &serverconfigs.AccessLogWriteTargets{ + File: true, + MySQL: true, + ClickHouse: false, + } + } + + return serverconfigs.ParseWriteTargetsFromPolicy(policy.WriteTargets, policy.Type, policy.DisableDefaultDB) +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_app.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_app.go new file mode 100644 index 0000000..6b64a1f --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_app.go @@ -0,0 +1,370 @@ +package httpdns + +import ( + "context" + "encoding/json" + "errors" + "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/TeaOSLab/EdgeCommon/pkg/systemconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/userconfigs" + "github.com/iwind/TeaGo/dbs" + "strings" + "time" + + "github.com/TeaOSLab/EdgeAPI/internal/db/models" +) + +// HTTPDNSAppService HTTPDNS应用服务 +type HTTPDNSAppService struct { + services.BaseService + pb.UnimplementedHTTPDNSAppServiceServer +} + +func (this *HTTPDNSAppService) CreateHTTPDNSApp(ctx context.Context, req *pb.CreateHTTPDNSAppRequest) (*pb.CreateHTTPDNSAppResponse, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + if userId > 0 { + req.UserId = userId + } + appName := strings.TrimSpace(req.Name) + appId := strings.TrimSpace(req.AppId) + if len(appName) == 0 || len(appId) == 0 { + return nil, errors.New("required 'name' and 'appId'") + } + var appDbId int64 + now := time.Now().Unix() + err = this.RunTx(func(tx *dbs.Tx) error { + // 用户端防重复提交:短时间内同用户同应用名仅创建一次。 + if req.UserId > 0 { + latest, err := models.SharedHTTPDNSAppDAO.FindLatestEnabledAppWithNameAndUser(tx, appName, req.UserId) + if err != nil { + return err + } + if latest != nil && int64(latest.CreatedAt) >= now-5 { + appDbId = int64(latest.Id) + secret, err := models.SharedHTTPDNSAppSecretDAO.FindEnabledAppSecret(tx, appDbId) + if err != nil { + return err + } + if secret == nil { + _, _, err = models.SharedHTTPDNSAppSecretDAO.InitAppSecret(tx, appDbId, req.SignEnabled) + if err != nil { + return err + } + } + return nil + } + } + + exists, err := models.SharedHTTPDNSAppDAO.FindEnabledAppWithAppId(tx, appId) + if err != nil { + return err + } + if exists != nil { + return errors.New("appId already exists") + } + + // 使用 clusterIdsJSON;若为空则从用户关联集群获取 + clusterIdsJSON := req.ClusterIdsJSON + if len(clusterIdsJSON) == 0 || string(clusterIdsJSON) == "[]" || string(clusterIdsJSON) == "null" { + // 读取用户关联的 HTTPDNS 集群 + if req.UserId > 0 { + user, userErr := models.SharedUserDAO.FindEnabledUser(tx, req.UserId, nil) + if userErr != nil { + return userErr + } + if user != nil && len(user.HttpdnsClusterIds) > 0 { + var userClusterIds []int64 + if json.Unmarshal([]byte(user.HttpdnsClusterIds), &userClusterIds) == nil && len(userClusterIds) > 0 { + clusterIdsJSON, _ = json.Marshal(userClusterIds) + } + } + } + } + + // 如果仍然没有集群,则不允许创建 + if len(clusterIdsJSON) == 0 || string(clusterIdsJSON) == "[]" || string(clusterIdsJSON) == "null" { + return errors.New("用户尚未分配 HTTPDNS 集群,无法创建应用") + } + + appDbId, err = models.SharedHTTPDNSAppDAO.CreateApp(tx, appName, appId, clusterIdsJSON, req.IsOn, req.UserId) + if err != nil { + return err + } + _, _, err = models.SharedHTTPDNSAppSecretDAO.InitAppSecret(tx, appDbId, req.SignEnabled) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByAppDbId(tx, appDbId, models.HTTPDNSNodeTaskTypeAppChanged) + }) + if err != nil { + return nil, err + } + return &pb.CreateHTTPDNSAppResponse{AppDbId: appDbId}, nil +} + +// readHTTPDNSDefaultClusterIdList reads default cluster IDs from UserRegisterConfig. +func readHTTPDNSDefaultClusterIdList(tx *dbs.Tx) ([]int64, error) { + // 优先从 UserRegisterConfig 中读取 + configJSON, err := models.SharedSysSettingDAO.ReadSetting(tx, systemconfigs.SettingCodeUserRegisterConfig) + if err != nil { + return nil, err + } + if len(configJSON) > 0 { + var config userconfigs.UserRegisterConfig + if err := json.Unmarshal(configJSON, &config); err == nil { + if len(config.HTTPDNSDefaultClusterIds) > 0 { + // 验证集群有效性 + var validIds []int64 + for _, id := range config.HTTPDNSDefaultClusterIds { + if id <= 0 { + continue + } + cluster, err := models.SharedHTTPDNSClusterDAO.FindEnabledCluster(tx, id) + if err != nil { + return nil, err + } + if cluster != nil && cluster.IsOn { + validIds = append(validIds, id) + } + } + if len(validIds) > 0 { + return validIds, nil + } + } + } + } + + return nil, nil +} + +func (this *HTTPDNSAppService) UpdateHTTPDNSApp(ctx context.Context, req *pb.UpdateHTTPDNSAppRequest) (*pb.RPCSuccess, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + oldApp, err := ensureAppAccess(tx, req.AppDbId, userId) + if err != nil { + return err + } + if oldApp == nil { + return errors.New("app not found") + } + + targetUserId := req.UserId + if targetUserId <= 0 { + targetUserId = oldApp.UserId + } + if userId > 0 { + targetUserId = userId + } + + err = models.SharedHTTPDNSAppDAO.UpdateApp(tx, req.AppDbId, req.Name, req.ClusterIdsJSON, req.IsOn, targetUserId) + if err != nil { + return err + } + + err = notifyHTTPDNSAppTasksByApp(tx, oldApp, models.HTTPDNSNodeTaskTypeAppChanged) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByAppDbId(tx, req.AppDbId, models.HTTPDNSNodeTaskTypeAppChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSAppService) DeleteHTTPDNSApp(ctx context.Context, req *pb.DeleteHTTPDNSAppRequest) (*pb.RPCSuccess, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + app, err := ensureAppAccess(tx, req.AppDbId, userId) + if err != nil { + return err + } + if app == nil { + return nil + } + + // 1) 先停用规则记录 + rules, err := models.SharedHTTPDNSCustomRuleDAO.ListEnabledRulesWithAppId(tx, req.AppDbId) + if err != nil { + return err + } + for _, rule := range rules { + err = models.SharedHTTPDNSCustomRuleRecordDAO.DisableRecordsWithRuleId(tx, int64(rule.Id)) + if err != nil { + return err + } + } + + // 2) 停用规则、域名、密钥 + err = models.SharedHTTPDNSCustomRuleDAO.DisableRulesWithAppId(tx, req.AppDbId) + if err != nil { + return err + } + err = models.SharedHTTPDNSDomainDAO.DisableDomainsWithAppId(tx, req.AppDbId) + if err != nil { + return err + } + err = models.SharedHTTPDNSAppSecretDAO.DisableAppSecret(tx, req.AppDbId) + if err != nil { + return err + } + + // 3) 删除该应用的 MySQL 访问日志,避免残留 + err = models.SharedHTTPDNSAccessLogDAO.DeleteLogsWithAppId(tx, app.AppId) + if err != nil { + return err + } + + // 4) 最后停用应用 + err = models.SharedHTTPDNSAppDAO.DisableApp(tx, req.AppDbId) + if err != nil { + return err + } + + return notifyHTTPDNSAppTasksByApp(tx, app, models.HTTPDNSNodeTaskTypeAppChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSAppService) FindHTTPDNSApp(ctx context.Context, req *pb.FindHTTPDNSAppRequest) (*pb.FindHTTPDNSAppResponse, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + app, err := ensureAppAccess(this.NullTx(), req.AppDbId, userId) + if err != nil { + return nil, err + } + if app == nil { + return &pb.FindHTTPDNSAppResponse{}, nil + } + secret, err := models.SharedHTTPDNSAppSecretDAO.FindEnabledAppSecret(this.NullTx(), req.AppDbId) + if err != nil { + return nil, err + } + return &pb.FindHTTPDNSAppResponse{App: toPBApp(app, secret)}, nil +} + +func (this *HTTPDNSAppService) ListHTTPDNSApps(ctx context.Context, req *pb.ListHTTPDNSAppsRequest) (*pb.ListHTTPDNSAppsResponse, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + var apps []*models.HTTPDNSApp + if userId > 0 { + apps, err = models.SharedHTTPDNSAppDAO.ListEnabledAppsWithUser(this.NullTx(), userId, req.Offset, req.Size, req.Keyword) + } else { + apps, err = models.SharedHTTPDNSAppDAO.ListEnabledApps(this.NullTx(), req.Offset, req.Size, req.Keyword) + } + if err != nil { + return nil, err + } + var pbApps []*pb.HTTPDNSApp + for _, app := range apps { + secret, err := models.SharedHTTPDNSAppSecretDAO.FindEnabledAppSecret(this.NullTx(), int64(app.Id)) + if err != nil { + return nil, err + } + pbApps = append(pbApps, toPBApp(app, secret)) + } + return &pb.ListHTTPDNSAppsResponse{Apps: pbApps}, nil +} + +func (this *HTTPDNSAppService) FindAllHTTPDNSApps(ctx context.Context, req *pb.FindAllHTTPDNSAppsRequest) (*pb.FindAllHTTPDNSAppsResponse, error) { + _, userId, validateErr := this.ValidateAdminAndUser(ctx, true) + if validateErr != nil { + if _, nodeErr := this.ValidateHTTPDNSNode(ctx); nodeErr != nil { + return nil, validateErr + } + } + var apps []*models.HTTPDNSApp + var err error + if validateErr == nil && userId > 0 { + apps, err = models.SharedHTTPDNSAppDAO.FindAllEnabledAppsWithUser(this.NullTx(), userId) + } else { + apps, err = models.SharedHTTPDNSAppDAO.FindAllEnabledApps(this.NullTx()) + } + if err != nil { + return nil, err + } + var pbApps []*pb.HTTPDNSApp + for _, app := range apps { + secret, err := models.SharedHTTPDNSAppSecretDAO.FindEnabledAppSecret(this.NullTx(), int64(app.Id)) + if err != nil { + return nil, err + } + pbApps = append(pbApps, toPBApp(app, secret)) + } + return &pb.FindAllHTTPDNSAppsResponse{Apps: pbApps}, nil +} + +func (this *HTTPDNSAppService) UpdateHTTPDNSAppSignEnabled(ctx context.Context, req *pb.UpdateHTTPDNSAppSignEnabledRequest) (*pb.RPCSuccess, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + app, err := ensureAppAccess(tx, req.AppDbId, userId) + if err != nil { + return err + } + if app == nil { + return errors.New("app not found") + } + + err = models.SharedHTTPDNSAppSecretDAO.UpdateSignEnabled(tx, req.AppDbId, req.SignEnabled) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByAppDbId(tx, req.AppDbId, models.HTTPDNSNodeTaskTypeAppChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSAppService) ResetHTTPDNSAppSignSecret(ctx context.Context, req *pb.ResetHTTPDNSAppSignSecretRequest) (*pb.ResetHTTPDNSAppSignSecretResponse, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + var signSecret string + var updatedAt int64 + err = this.RunTx(func(tx *dbs.Tx) error { + app, err := ensureAppAccess(tx, req.AppDbId, userId) + if err != nil { + return err + } + if app == nil { + return errors.New("app not found") + } + + signSecret, updatedAt, err = models.SharedHTTPDNSAppSecretDAO.ResetSignSecret(tx, req.AppDbId) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByAppDbId(tx, req.AppDbId, models.HTTPDNSNodeTaskTypeAppChanged) + }) + if err != nil { + return nil, err + } + return &pb.ResetHTTPDNSAppSignSecretResponse{ + SignSecret: signSecret, + UpdatedAt: updatedAt, + }, nil +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_cluster.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_cluster.go new file mode 100644 index 0000000..94da75f --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_cluster.go @@ -0,0 +1,216 @@ +package httpdns + +import ( + "context" + "errors" + "fmt" + "strings" + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/dbs" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// HTTPDNSClusterService HTTPDNS集群服务 +type HTTPDNSClusterService struct { + services.BaseService + pb.UnimplementedHTTPDNSClusterServiceServer +} + +func (this *HTTPDNSClusterService) CreateHTTPDNSCluster(ctx context.Context, req *pb.CreateHTTPDNSClusterRequest) (*pb.CreateHTTPDNSClusterResponse, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + if len(req.Name) == 0 { + return nil, errors.New("required 'name'") + } + var clusterId int64 + err = this.RunTx(func(tx *dbs.Tx) error { + clusterId, err = models.SharedHTTPDNSClusterDAO.CreateCluster(tx, req.Name, req.ServiceDomain, req.DefaultTTL, req.FallbackTimeoutMs, req.InstallDir, req.TlsPolicyJSON, req.IsOn, req.IsDefault, req.AutoRemoteStart, req.AccessLogIsOn, req.TimeZone) + if err != nil { + return err + } + return notifyHTTPDNSClusterTask(tx, clusterId, models.HTTPDNSNodeTaskTypeConfigChanged) + }) + if err != nil { + return nil, err + } + return &pb.CreateHTTPDNSClusterResponse{ClusterId: clusterId}, nil +} + +func (this *HTTPDNSClusterService) UpdateHTTPDNSCluster(ctx context.Context, req *pb.UpdateHTTPDNSClusterRequest) (*pb.RPCSuccess, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + + // Compatibility fallback: + // If protobuf schemas between edge-admin and edge-api are inconsistent, + // these newly-added fields may be lost on the wire. Read gRPC metadata as fallback. + if md, ok := metadata.FromIncomingContext(ctx); ok { + if values := md.Get("x-httpdns-auto-remote-start"); len(values) > 0 { + raw := strings.ToLower(strings.TrimSpace(values[0])) + req.AutoRemoteStart = raw == "1" || raw == "true" || raw == "on" || raw == "yes" || raw == "enabled" + } + if values := md.Get("x-httpdns-access-log-is-on"); len(values) > 0 { + raw := strings.ToLower(strings.TrimSpace(values[0])) + req.AccessLogIsOn = raw == "1" || raw == "true" || raw == "on" || raw == "yes" || raw == "enabled" + } + if values := md.Get("x-httpdns-time-zone"); len(values) > 0 { + raw := strings.TrimSpace(values[0]) + if len(raw) > 0 { + req.TimeZone = raw + } + } + } + + err = this.RunTx(func(tx *dbs.Tx) error { + // 先读取旧的 TLS 配置,用于判断是否真正发生了变化 + var oldTLSJSON string + oldCluster, findErr := models.SharedHTTPDNSClusterDAO.FindEnabledCluster(tx, req.ClusterId) + if findErr == nil && oldCluster != nil { + oldTLSJSON = string(oldCluster.TLSPolicy) + } + + err = models.SharedHTTPDNSClusterDAO.UpdateCluster(tx, req.ClusterId, req.Name, req.ServiceDomain, req.DefaultTTL, req.FallbackTimeoutMs, req.InstallDir, req.TlsPolicyJSON, req.IsOn, req.IsDefault, req.AutoRemoteStart, req.AccessLogIsOn, req.TimeZone) + if err != nil { + return err + } + taskType := models.HTTPDNSNodeTaskTypeConfigChanged + if len(req.TlsPolicyJSON) > 0 && string(req.TlsPolicyJSON) != oldTLSJSON { + taskType = models.HTTPDNSNodeTaskTypeTLSChanged + } + return notifyHTTPDNSClusterTask(tx, req.ClusterId, taskType) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSClusterService) DeleteHTTPDNSCluster(ctx context.Context, req *pb.DeleteHTTPDNSClusterRequest) (*pb.RPCSuccess, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + err = models.SharedHTTPDNSClusterDAO.DisableCluster(tx, req.ClusterId) + if err != nil { + return err + } + return notifyHTTPDNSClusterTask(tx, req.ClusterId, models.HTTPDNSNodeTaskTypeConfigChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSClusterService) FindHTTPDNSCluster(ctx context.Context, req *pb.FindHTTPDNSClusterRequest) (*pb.FindHTTPDNSClusterResponse, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + cluster, err := models.SharedHTTPDNSClusterDAO.FindEnabledCluster(this.NullTx(), req.ClusterId) + if err != nil { + return nil, err + } + if cluster != nil { + _ = grpc.SetHeader(ctx, metadata.Pairs( + "x-httpdns-auto-remote-start", fmt.Sprintf("%t", cluster.AutoRemoteStart), + "x-httpdns-access-log-is-on", fmt.Sprintf("%t", cluster.AccessLogIsOn), + "x-httpdns-time-zone", cluster.TimeZone, + )) + } + return &pb.FindHTTPDNSClusterResponse{Cluster: toPBCluster(cluster)}, nil +} + +func (this *HTTPDNSClusterService) ListHTTPDNSClusters(ctx context.Context, req *pb.ListHTTPDNSClustersRequest) (*pb.ListHTTPDNSClustersResponse, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + clusters, err := models.SharedHTTPDNSClusterDAO.ListEnabledClusters(this.NullTx(), req.Offset, req.Size, req.Keyword) + if err != nil { + return nil, err + } + var pbClusters []*pb.HTTPDNSCluster + for _, cluster := range clusters { + pbClusters = append(pbClusters, toPBCluster(cluster)) + } + return &pb.ListHTTPDNSClustersResponse{Clusters: pbClusters}, nil +} + +func (this *HTTPDNSClusterService) FindAllHTTPDNSClusters(ctx context.Context, req *pb.FindAllHTTPDNSClustersRequest) (*pb.FindAllHTTPDNSClustersResponse, error) { + _, _, validateErr := this.ValidateAdminAndUser(ctx, true) + isNode := false + if validateErr != nil { + if _, nodeErr := this.ValidateHTTPDNSNode(ctx); nodeErr != nil { + return nil, validateErr + } + isNode = true + } + clusters, err := models.SharedHTTPDNSClusterDAO.FindAllEnabledClusters(this.NullTx()) + if err != nil { + return nil, err + } + var pbClusters []*pb.HTTPDNSCluster + for _, cluster := range clusters { + if isNode { + // 节点调用时解析证书引用,嵌入实际 PEM 数据 + pbClusters = append(pbClusters, toPBClusterWithResolvedCerts(this.NullTx(), cluster)) + } else { + pbClusters = append(pbClusters, toPBCluster(cluster)) + } + } + return &pb.FindAllHTTPDNSClustersResponse{Clusters: pbClusters}, nil +} + +func (this *HTTPDNSClusterService) UpdateHTTPDNSClusterDefault(ctx context.Context, req *pb.UpdateHTTPDNSClusterDefaultRequest) (*pb.RPCSuccess, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + err = models.SharedHTTPDNSClusterDAO.UpdateDefaultCluster(tx, req.ClusterId) + if err != nil { + return err + } + + clusters, err := models.SharedHTTPDNSClusterDAO.FindAllEnabledClusters(tx) + if err != nil { + return err + } + + for _, cluster := range clusters { + err = notifyHTTPDNSClusterTask(tx, int64(cluster.Id), models.HTTPDNSNodeTaskTypeConfigChanged) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSClusterService) ListHTTPDNSNodesWithClusterId(ctx context.Context, req *pb.ListHTTPDNSNodesWithClusterIdRequest) (*pb.ListHTTPDNSNodesWithClusterIdResponse, error) { + _, _, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + nodes, err := models.SharedHTTPDNSNodeDAO.ListEnabledNodes(this.NullTx(), req.ClusterId) + if err != nil { + return nil, err + } + var pbNodes []*pb.HTTPDNSNode + for _, node := range nodes { + pbNodes = append(pbNodes, toPBNode(node)) + } + return &pb.ListHTTPDNSNodesWithClusterIdResponse{Nodes: pbNodes}, nil +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_domain.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_domain.go new file mode 100644 index 0000000..0f93cc3 --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_domain.go @@ -0,0 +1,128 @@ +package httpdns + +import ( + "context" + "errors" + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/dbs" +) + +// HTTPDNSDomainService HTTPDNS域名服务 +type HTTPDNSDomainService struct { + services.BaseService + pb.UnimplementedHTTPDNSDomainServiceServer +} + +func (this *HTTPDNSDomainService) CreateHTTPDNSDomain(ctx context.Context, req *pb.CreateHTTPDNSDomainRequest) (*pb.CreateHTTPDNSDomainResponse, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + if req.AppDbId <= 0 || len(req.Domain) == 0 { + return nil, errors.New("required 'appDbId' and 'domain'") + } + var domainId int64 + err = this.RunTx(func(tx *dbs.Tx) error { + app, err := ensureAppAccess(tx, req.AppDbId, userId) + if err != nil { + return err + } + if app == nil { + return errors.New("app not found") + } + + domainId, err = models.SharedHTTPDNSDomainDAO.CreateDomain(tx, req.AppDbId, req.Domain, req.IsOn) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByAppDbId(tx, req.AppDbId, models.HTTPDNSNodeTaskTypeDomainChanged) + }) + if err != nil { + return nil, err + } + return &pb.CreateHTTPDNSDomainResponse{DomainId: domainId}, nil +} + +func (this *HTTPDNSDomainService) DeleteHTTPDNSDomain(ctx context.Context, req *pb.DeleteHTTPDNSDomainRequest) (*pb.RPCSuccess, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + domain, app, err := ensureDomainAccess(tx, req.DomainId, userId) + if err != nil { + return err + } + if domain == nil { + return nil + } + + err = models.SharedHTTPDNSDomainDAO.DisableDomain(tx, req.DomainId) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByAppDbId(tx, int64(app.Id), models.HTTPDNSNodeTaskTypeDomainChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSDomainService) UpdateHTTPDNSDomainStatus(ctx context.Context, req *pb.UpdateHTTPDNSDomainStatusRequest) (*pb.RPCSuccess, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + domain, app, err := ensureDomainAccess(tx, req.DomainId, userId) + if err != nil { + return err + } + if domain == nil { + return nil + } + + err = models.SharedHTTPDNSDomainDAO.UpdateDomainStatus(tx, req.DomainId, req.IsOn) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByAppDbId(tx, int64(app.Id), models.HTTPDNSNodeTaskTypeDomainChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSDomainService) ListHTTPDNSDomainsWithAppId(ctx context.Context, req *pb.ListHTTPDNSDomainsWithAppIdRequest) (*pb.ListHTTPDNSDomainsWithAppIdResponse, error) { + _, userId, validateErr := this.ValidateAdminAndUser(ctx, true) + if validateErr != nil { + if _, nodeErr := this.ValidateHTTPDNSNode(ctx); nodeErr != nil { + return nil, validateErr + } + } else if userId > 0 { + app, err := ensureAppAccess(this.NullTx(), req.AppDbId, userId) + if err != nil { + return nil, err + } + if app == nil { + return &pb.ListHTTPDNSDomainsWithAppIdResponse{}, nil + } + } + domains, err := models.SharedHTTPDNSDomainDAO.ListEnabledDomainsWithAppId(this.NullTx(), req.AppDbId, req.Keyword) + if err != nil { + return nil, err + } + var pbDomains []*pb.HTTPDNSDomain + for _, domain := range domains { + ruleCount, err := models.SharedHTTPDNSCustomRuleDAO.CountEnabledRulesWithDomainId(this.NullTx(), int64(domain.Id)) + if err != nil { + return nil, err + } + pbDomains = append(pbDomains, toPBDomain(domain, ruleCount)) + } + return &pb.ListHTTPDNSDomainsWithAppIdResponse{Domains: pbDomains}, nil +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_node.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_node.go new file mode 100644 index 0000000..a12ce8e --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_node.go @@ -0,0 +1,409 @@ +package httpdns + +import ( + "context" + "encoding/json" + "errors" + "io" + "path/filepath" + + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/goman" + "github.com/TeaOSLab/EdgeAPI/internal/installers" + "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" + "github.com/TeaOSLab/EdgeAPI/internal/setup" + rpcutils "github.com/TeaOSLab/EdgeAPI/internal/rpc/utils" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/dbs" + "github.com/iwind/TeaGo/logs" + stringutil "github.com/iwind/TeaGo/utils/string" +) + +// HTTPDNSNodeService HTTPDNS节点服务 +type HTTPDNSNodeService struct { + services.BaseService + pb.UnimplementedHTTPDNSNodeServiceServer +} + +func (this *HTTPDNSNodeService) CreateHTTPDNSNode(ctx context.Context, req *pb.CreateHTTPDNSNodeRequest) (*pb.CreateHTTPDNSNodeResponse, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + if req.ClusterId <= 0 { + return nil, errors.New("required 'clusterId'") + } + var nodeId int64 + err = this.RunTx(func(tx *dbs.Tx) error { + nodeId, err = models.SharedHTTPDNSNodeDAO.CreateNode(tx, req.ClusterId, req.Name, req.InstallDir, req.IsOn) + if err != nil { + return err + } + return notifyHTTPDNSClusterTask(tx, req.ClusterId, models.HTTPDNSNodeTaskTypeConfigChanged) + }) + if err != nil { + return nil, err + } + return &pb.CreateHTTPDNSNodeResponse{NodeId: nodeId}, nil +} + +func (this *HTTPDNSNodeService) UpdateHTTPDNSNode(ctx context.Context, req *pb.UpdateHTTPDNSNodeRequest) (*pb.RPCSuccess, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + node, err := models.SharedHTTPDNSNodeDAO.FindEnabledNode(tx, req.NodeId) + if err != nil { + return err + } + if node == nil { + return errors.New("node not found") + } + + err = models.SharedHTTPDNSNodeDAO.UpdateNode(tx, req.NodeId, req.Name, req.InstallDir, req.IsOn) + if err != nil { + return err + } + return notifyHTTPDNSClusterTask(tx, int64(node.ClusterId), models.HTTPDNSNodeTaskTypeConfigChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSNodeService) DeleteHTTPDNSNode(ctx context.Context, req *pb.DeleteHTTPDNSNodeRequest) (*pb.RPCSuccess, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + node, err := models.SharedHTTPDNSNodeDAO.FindEnabledNode(tx, req.NodeId) + if err != nil { + return err + } + if node == nil { + return nil + } + + err = models.SharedHTTPDNSNodeDAO.DisableNode(tx, req.NodeId) + if err != nil { + return err + } + return notifyHTTPDNSClusterTask(tx, int64(node.ClusterId), models.HTTPDNSNodeTaskTypeConfigChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSNodeService) FindHTTPDNSNode(ctx context.Context, req *pb.FindHTTPDNSNodeRequest) (*pb.FindHTTPDNSNodeResponse, error) { + nodeId := req.NodeId + if nodeId <= 0 { + parsedNodeId, nodeErr := this.ValidateHTTPDNSNode(ctx) + if nodeErr != nil { + return nil, errors.New("invalid 'nodeId'") + } + nodeId = parsedNodeId + } else { + _, _, validateErr := this.ValidateAdminAndUser(ctx, true) + if validateErr != nil { + if _, nodeErr := this.ValidateHTTPDNSNode(ctx); nodeErr != nil { + return nil, validateErr + } + } + } + + node, err := models.SharedHTTPDNSNodeDAO.FindEnabledNode(this.NullTx(), nodeId) + if err != nil { + return nil, err + } + + pbNode := toPBNode(node) + + // 认证信息 + if pbNode != nil { + login, loginErr := models.SharedNodeLoginDAO.FindEnabledNodeLoginWithNodeId(this.NullTx(), nodeconfigs.NodeRoleHTTPDNS, nodeId) + if loginErr != nil { + return nil, loginErr + } + if login != nil { + pbNode.NodeLogin = &pb.NodeLogin{ + Id: int64(login.Id), + Name: login.Name, + Type: login.Type, + Params: login.Params, + } + } + } + + return &pb.FindHTTPDNSNodeResponse{Node: pbNode}, nil +} + +func (this *HTTPDNSNodeService) ListHTTPDNSNodes(ctx context.Context, req *pb.ListHTTPDNSNodesRequest) (*pb.ListHTTPDNSNodesResponse, error) { + _, _, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + nodes, err := models.SharedHTTPDNSNodeDAO.ListEnabledNodes(this.NullTx(), req.ClusterId) + if err != nil { + return nil, err + } + var pbNodes []*pb.HTTPDNSNode + for _, node := range nodes { + pbNodes = append(pbNodes, toPBNode(node)) + } + return &pb.ListHTTPDNSNodesResponse{Nodes: pbNodes}, nil +} + +func (this *HTTPDNSNodeService) UpdateHTTPDNSNodeStatus(ctx context.Context, req *pb.UpdateHTTPDNSNodeStatusRequest) (*pb.RPCSuccess, error) { + nodeId := req.GetNodeId() + isAdminCaller := false + if nodeId > 0 { + if _, adminErr := this.ValidateAdmin(ctx); adminErr == nil { + isAdminCaller = true + } + } + if !isAdminCaller { + if nodeId <= 0 { + parsedNodeId, err := this.ValidateHTTPDNSNode(ctx) + if err != nil { + return nil, err + } + nodeId = parsedNodeId + } + } + if nodeId <= 0 { + return nil, errors.New("invalid 'nodeId'") + } + + err := models.SharedHTTPDNSNodeDAO.UpdateNodeStatus(this.NullTx(), nodeId, req.GetIsUp(), req.GetIsInstalled(), req.GetIsActive(), req.GetStatusJSON(), req.GetInstallStatusJSON()) + if err != nil { + return nil, err + } + + if isAdminCaller && shouldTriggerHTTPDNSInstall(req.GetInstallStatusJSON()) { + goman.New(func() { + installErr := installers.SharedHTTPDNSNodeQueue().InstallNodeProcess(nodeId, false) + if installErr != nil { + logs.Println("[RPC][HTTPDNS]install node failed:", installErr.Error()) + } + }) + } + + return this.Success() +} + +// UpdateHTTPDNSNodeLogin 修改HTTPDNS节点登录信息 +func (this *HTTPDNSNodeService) UpdateHTTPDNSNodeLogin(ctx context.Context, req *pb.UpdateHTTPDNSNodeLoginRequest) (*pb.RPCSuccess, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + + var tx = this.NullTx() + + if req.NodeLogin.Id <= 0 { + loginId, createErr := models.SharedNodeLoginDAO.CreateNodeLogin(tx, nodeconfigs.NodeRoleHTTPDNS, req.NodeId, req.NodeLogin.Name, req.NodeLogin.Type, req.NodeLogin.Params) + if createErr != nil { + return nil, createErr + } + req.NodeLogin.Id = loginId + } + + err = models.SharedNodeLoginDAO.UpdateNodeLogin(tx, req.NodeLogin.Id, req.NodeLogin.Name, req.NodeLogin.Type, req.NodeLogin.Params) + if err != nil { + return nil, err + } + + return this.Success() +} + +// CheckHTTPDNSNodeLatestVersion 检查HTTPDNS节点新版本 +func (this *HTTPDNSNodeService) CheckHTTPDNSNodeLatestVersion(ctx context.Context, req *pb.CheckHTTPDNSNodeLatestVersionRequest) (*pb.CheckHTTPDNSNodeLatestVersionResponse, error) { + _, _, _, err := rpcutils.ValidateRequest(ctx, rpcutils.UserTypeAdmin, rpcutils.UserTypeHTTPDNS) + if err != nil { + return nil, err + } + + deployFiles := installers.SharedDeployManager.LoadHTTPDNSNodeFiles() + for _, file := range deployFiles { + if file.OS == req.Os && file.Arch == req.Arch && stringutil.VersionCompare(file.Version, req.CurrentVersion) > 0 { + return &pb.CheckHTTPDNSNodeLatestVersionResponse{ + HasNewVersion: true, + NewVersion: file.Version, + }, nil + } + } + return &pb.CheckHTTPDNSNodeLatestVersionResponse{HasNewVersion: false}, nil +} + +// DownloadHTTPDNSNodeInstallationFile 下载最新HTTPDNS节点安装文件 +func (this *HTTPDNSNodeService) DownloadHTTPDNSNodeInstallationFile(ctx context.Context, req *pb.DownloadHTTPDNSNodeInstallationFileRequest) (*pb.DownloadHTTPDNSNodeInstallationFileResponse, error) { + nodeId, err := this.ValidateHTTPDNSNode(ctx) + if err != nil { + return nil, err + } + + // 检查自动升级开关 + upgradeConfig, _ := setup.LoadUpgradeConfig() + if upgradeConfig != nil && !upgradeConfig.AutoUpgrade { + return &pb.DownloadHTTPDNSNodeInstallationFileResponse{}, nil + } + + var file = installers.SharedDeployManager.FindHTTPDNSNodeFile(req.Os, req.Arch) + if file == nil { + return &pb.DownloadHTTPDNSNodeInstallationFileResponse{}, nil + } + + sum, err := file.Sum() + if err != nil { + return nil, err + } + + data, offset, err := file.Read(req.ChunkOffset) + if err != nil && err != io.EOF { + return nil, err + } + + // 增加下载速度监控 + installers.SharedUpgradeLimiter.UpdateNodeBytes(nodeconfigs.NodeRoleHTTPDNS, nodeId, int64(len(data))) + + return &pb.DownloadHTTPDNSNodeInstallationFileResponse{ + Sum: sum, + Offset: offset, + ChunkData: data, + Version: file.Version, + Filename: filepath.Base(file.Path), + }, nil +} + +// CountAllUpgradeHTTPDNSNodesWithClusterId 计算需要升级的HTTPDNS节点数量 +func (this *HTTPDNSNodeService) CountAllUpgradeHTTPDNSNodesWithClusterId(ctx context.Context, req *pb.CountAllUpgradeHTTPDNSNodesWithClusterIdRequest) (*pb.RPCCountResponse, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + + var tx = this.NullTx() + deployFiles := installers.SharedDeployManager.LoadHTTPDNSNodeFiles() + total := int64(0) + for _, deployFile := range deployFiles { + count, err := models.SharedHTTPDNSNodeDAO.CountAllLowerVersionNodesWithClusterId(tx, req.ClusterId, deployFile.OS, deployFile.Arch, deployFile.Version) + if err != nil { + return nil, err + } + total += count + } + return this.SuccessCount(total) +} + +// FindAllUpgradeHTTPDNSNodesWithClusterId 列出所有需要升级的HTTPDNS节点 +func (this *HTTPDNSNodeService) FindAllUpgradeHTTPDNSNodesWithClusterId(ctx context.Context, req *pb.FindAllUpgradeHTTPDNSNodesWithClusterIdRequest) (*pb.FindAllUpgradeHTTPDNSNodesWithClusterIdResponse, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + + var tx = this.NullTx() + deployFiles := installers.SharedDeployManager.LoadHTTPDNSNodeFiles() + var result []*pb.FindAllUpgradeHTTPDNSNodesWithClusterIdResponse_HTTPDNSNodeUpgrade + for _, deployFile := range deployFiles { + nodes, err := models.SharedHTTPDNSNodeDAO.FindAllLowerVersionNodesWithClusterId(tx, req.ClusterId, deployFile.OS, deployFile.Arch, deployFile.Version) + if err != nil { + return nil, err + } + for _, node := range nodes { + // 解析状态获取当前版本 + var oldVersion string + if len(node.Status) > 0 { + var statusMap map[string]interface{} + if json.Unmarshal(node.Status, &statusMap) == nil { + if v, ok := statusMap["buildVersion"]; ok { + oldVersion, _ = v.(string) + } + } + } + + pbNode := toPBNode(node) + + // 认证信息 + login, loginErr := models.SharedNodeLoginDAO.FindEnabledNodeLoginWithNodeId(tx, nodeconfigs.NodeRoleHTTPDNS, int64(node.Id)) + if loginErr != nil { + return nil, loginErr + } + if login != nil && pbNode != nil { + pbNode.NodeLogin = &pb.NodeLogin{ + Id: int64(login.Id), + Name: login.Name, + Type: login.Type, + Params: login.Params, + } + } + + result = append(result, &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdResponse_HTTPDNSNodeUpgrade{ + Node: pbNode, + Os: deployFile.OS, + Arch: deployFile.Arch, + OldVersion: oldVersion, + NewVersion: deployFile.Version, + }) + } + } + return &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdResponse{Nodes: result}, nil +} + +// UpgradeHTTPDNSNode 升级单个HTTPDNS节点 +func (this *HTTPDNSNodeService) UpgradeHTTPDNSNode(ctx context.Context, req *pb.UpgradeHTTPDNSNodeRequest) (*pb.RPCSuccess, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + + var tx = this.NullTx() + + err = models.SharedHTTPDNSNodeDAO.UpdateNodeIsInstalled(tx, req.NodeId, false) + if err != nil { + return nil, err + } + + // 重置安装状态 + installStatus, err := models.SharedHTTPDNSNodeDAO.FindNodeInstallStatus(tx, req.NodeId) + if err != nil { + return nil, err + } + if installStatus == nil { + installStatus = &models.NodeInstallStatus{} + } + installStatus.IsOk = false + installStatus.IsFinished = false + err = models.SharedHTTPDNSNodeDAO.UpdateNodeInstallStatus(tx, req.NodeId, installStatus) + if err != nil { + return nil, err + } + + goman.New(func() { + installErr := installers.SharedHTTPDNSNodeQueue().InstallNodeProcess(req.NodeId, true) + if installErr != nil { + logs.Println("[RPC][HTTPDNS]upgrade node failed:", installErr.Error()) + } + }) + + return this.Success() +} + +func shouldTriggerHTTPDNSInstall(installStatusJSON []byte) bool { + if len(installStatusJSON) == 0 { + return false + } + + installStatus := &models.NodeInstallStatus{} + err := json.Unmarshal(installStatusJSON, installStatus) + if err != nil { + return false + } + return installStatus.IsRunning && !installStatus.IsFinished +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_rule.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_rule.go new file mode 100644 index 0000000..6e397dc --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_rule.go @@ -0,0 +1,206 @@ +package httpdns + +import ( + "context" + "errors" + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/dbs" +) + +// HTTPDNSRuleService HTTPDNS规则服务 +type HTTPDNSRuleService struct { + services.BaseService + pb.UnimplementedHTTPDNSRuleServiceServer +} + +func (this *HTTPDNSRuleService) CreateHTTPDNSCustomRule(ctx context.Context, req *pb.CreateHTTPDNSCustomRuleRequest) (*pb.CreateHTTPDNSCustomRuleResponse, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + if req.Rule == nil { + return nil, errors.New("required 'rule'") + } + var ruleId int64 + err = this.RunTx(func(tx *dbs.Tx) error { + domain, app, err := ensureDomainAccess(tx, req.Rule.DomainId, userId) + if err != nil { + return err + } + if domain == nil || app == nil { + return errors.New("domain not found") + } + + rule := &models.HTTPDNSCustomRule{ + AppId: domain.AppId, + DomainId: uint32(req.Rule.DomainId), + RuleName: req.Rule.RuleName, + LineScope: req.Rule.LineScope, + LineCarrier: req.Rule.LineCarrier, + LineRegion: req.Rule.LineRegion, + LineProvince: req.Rule.LineProvince, + LineContinent: req.Rule.LineContinent, + LineCountry: req.Rule.LineCountry, + TTL: req.Rule.Ttl, + IsOn: req.Rule.IsOn, + Priority: req.Rule.Priority, + } + ruleId, err = models.SharedHTTPDNSCustomRuleDAO.CreateRule(tx, rule) + if err != nil { + return err + } + for _, record := range req.Rule.Records { + _, err := models.SharedHTTPDNSCustomRuleRecordDAO.CreateRecord(tx, ruleId, record.RecordType, record.RecordValue, record.Weight, record.Sort) + if err != nil { + return err + } + } + return notifyHTTPDNSAppTasksByAppDbId(tx, int64(app.Id), models.HTTPDNSNodeTaskTypeRuleChanged) + }) + if err != nil { + return nil, err + } + return &pb.CreateHTTPDNSCustomRuleResponse{RuleId: ruleId}, nil +} + +func (this *HTTPDNSRuleService) UpdateHTTPDNSCustomRule(ctx context.Context, req *pb.UpdateHTTPDNSCustomRuleRequest) (*pb.RPCSuccess, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + if req.Rule == nil || req.Rule.Id <= 0 { + return nil, errors.New("invalid 'rule.id'") + } + err = this.RunTx(func(tx *dbs.Tx) error { + oldRule, app, err := ensureRuleAccess(tx, req.Rule.Id, userId) + if err != nil { + return err + } + if oldRule == nil { + return errors.New("rule not found") + } + + rule := &models.HTTPDNSCustomRule{ + Id: uint32(req.Rule.Id), + RuleName: req.Rule.RuleName, + LineScope: req.Rule.LineScope, + LineCarrier: req.Rule.LineCarrier, + LineRegion: req.Rule.LineRegion, + LineProvince: req.Rule.LineProvince, + LineContinent: req.Rule.LineContinent, + LineCountry: req.Rule.LineCountry, + TTL: req.Rule.Ttl, + IsOn: req.Rule.IsOn, + Priority: req.Rule.Priority, + } + err = models.SharedHTTPDNSCustomRuleDAO.UpdateRule(tx, rule) + if err != nil { + return err + } + err = models.SharedHTTPDNSCustomRuleRecordDAO.DisableRecordsWithRuleId(tx, req.Rule.Id) + if err != nil { + return err + } + for _, record := range req.Rule.Records { + _, err := models.SharedHTTPDNSCustomRuleRecordDAO.CreateRecord(tx, req.Rule.Id, record.RecordType, record.RecordValue, record.Weight, record.Sort) + if err != nil { + return err + } + } + err = notifyHTTPDNSAppTasksByAppDbId(tx, int64(app.Id), models.HTTPDNSNodeTaskTypeRuleChanged) + if err != nil { + return err + } + + targetAppDbId := int64(app.Id) + return notifyHTTPDNSAppTasksByAppDbId(tx, targetAppDbId, models.HTTPDNSNodeTaskTypeRuleChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSRuleService) DeleteHTTPDNSCustomRule(ctx context.Context, req *pb.DeleteHTTPDNSCustomRuleRequest) (*pb.RPCSuccess, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + rule, app, err := ensureRuleAccess(tx, req.RuleId, userId) + if err != nil { + return err + } + if rule == nil { + return nil + } + + err = models.SharedHTTPDNSCustomRuleDAO.DisableRule(tx, req.RuleId) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByAppDbId(tx, int64(app.Id), models.HTTPDNSNodeTaskTypeRuleChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSRuleService) UpdateHTTPDNSCustomRuleStatus(ctx context.Context, req *pb.UpdateHTTPDNSCustomRuleStatusRequest) (*pb.RPCSuccess, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + err = this.RunTx(func(tx *dbs.Tx) error { + rule, app, err := ensureRuleAccess(tx, req.RuleId, userId) + if err != nil { + return err + } + if rule == nil { + return nil + } + + err = models.SharedHTTPDNSCustomRuleDAO.UpdateRuleStatus(tx, req.RuleId, req.IsOn) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByAppDbId(tx, int64(app.Id), models.HTTPDNSNodeTaskTypeRuleChanged) + }) + if err != nil { + return nil, err + } + return this.Success() +} + +func (this *HTTPDNSRuleService) ListHTTPDNSCustomRulesWithDomainId(ctx context.Context, req *pb.ListHTTPDNSCustomRulesWithDomainIdRequest) (*pb.ListHTTPDNSCustomRulesWithDomainIdResponse, error) { + _, userId, validateErr := this.ValidateAdminAndUser(ctx, true) + if validateErr != nil { + if _, nodeErr := this.ValidateHTTPDNSNode(ctx); nodeErr != nil { + return nil, validateErr + } + } else if userId > 0 { + domain, _, err := ensureDomainAccess(this.NullTx(), req.DomainId, userId) + if err != nil { + return nil, err + } + if domain == nil { + return &pb.ListHTTPDNSCustomRulesWithDomainIdResponse{}, nil + } + } + rules, err := models.SharedHTTPDNSCustomRuleDAO.ListEnabledRulesWithDomainId(this.NullTx(), req.DomainId) + if err != nil { + return nil, err + } + var pbRules []*pb.HTTPDNSCustomRule + for _, rule := range rules { + records, err := models.SharedHTTPDNSCustomRuleRecordDAO.ListEnabledRecordsWithRuleId(this.NullTx(), int64(rule.Id)) + if err != nil { + return nil, err + } + pbRules = append(pbRules, toPBRule(rule, records)) + } + return &pb.ListHTTPDNSCustomRulesWithDomainIdResponse{Rules: pbRules}, nil +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_runtime_log.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_runtime_log.go new file mode 100644 index 0000000..a56ef93 --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_runtime_log.go @@ -0,0 +1,107 @@ +package httpdns + +import ( + "context" + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + timeutil "github.com/iwind/TeaGo/utils/time" + "time" +) + +// HTTPDNSRuntimeLogService HTTPDNS运行日志服务 +type HTTPDNSRuntimeLogService struct { + services.BaseService + pb.UnimplementedHTTPDNSRuntimeLogServiceServer +} + +func (this *HTTPDNSRuntimeLogService) CreateHTTPDNSRuntimeLogs(ctx context.Context, req *pb.CreateHTTPDNSRuntimeLogsRequest) (*pb.CreateHTTPDNSRuntimeLogsResponse, error) { + nodeIdInContext, err := this.ValidateHTTPDNSNode(ctx) + if err != nil { + _, err = this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + } + for _, item := range req.Logs { + createdAt := item.CreatedAt + if createdAt <= 0 { + createdAt = time.Now().Unix() + } + day := item.Day + if len(day) == 0 { + day = timeutil.Format("Ymd") + } + nodeId := item.NodeId + // When called by HTTPDNS node, trust node id parsed from RPC token. + if nodeIdInContext > 0 { + nodeId = nodeIdInContext + } + + clusterId := item.ClusterId + if clusterId <= 0 && nodeId > 0 { + clusterId, _ = models.SharedHTTPDNSNodeDAO.FindNodeClusterId(this.NullTx(), nodeId) + } + + log := &models.HTTPDNSRuntimeLog{ + ClusterId: uint32(clusterId), + NodeId: uint32(nodeId), + Level: item.Level, + Type: item.Type, + Module: item.Module, + Description: item.Description, + Count: item.Count, + RequestId: item.RequestId, + CreatedAt: uint64(createdAt), + Day: day, + } + err := models.SharedHTTPDNSRuntimeLogDAO.CreateLog(this.NullTx(), log) + if err != nil { + return nil, err + } + } + return &pb.CreateHTTPDNSRuntimeLogsResponse{}, nil +} + +func (this *HTTPDNSRuntimeLogService) ListHTTPDNSRuntimeLogs(ctx context.Context, req *pb.ListHTTPDNSRuntimeLogsRequest) (*pb.ListHTTPDNSRuntimeLogsResponse, error) { + _, _, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + total, err := models.SharedHTTPDNSRuntimeLogDAO.CountLogs(this.NullTx(), req.Day, req.ClusterId, req.NodeId, req.Level, req.Keyword) + if err != nil { + return nil, err + } + logs, err := models.SharedHTTPDNSRuntimeLogDAO.ListLogs(this.NullTx(), req.Day, req.ClusterId, req.NodeId, req.Level, req.Keyword, req.Offset, req.Size) + if err != nil { + return nil, err + } + var pbLogs []*pb.HTTPDNSRuntimeLog + for _, item := range logs { + clusterName, _ := models.SharedHTTPDNSClusterDAO.FindEnabledClusterName(this.NullTx(), int64(item.ClusterId)) + nodeName := "" + node, _ := models.SharedHTTPDNSNodeDAO.FindEnabledNode(this.NullTx(), int64(item.NodeId)) + if node != nil { + nodeName = node.Name + } + pbLogs = append(pbLogs, &pb.HTTPDNSRuntimeLog{ + Id: int64(item.Id), + ClusterId: int64(item.ClusterId), + NodeId: int64(item.NodeId), + Level: item.Level, + Type: item.Type, + Module: item.Module, + Description: item.Description, + Count: item.Count, + RequestId: item.RequestId, + CreatedAt: int64(item.CreatedAt), + Day: item.Day, + ClusterName: clusterName, + NodeName: nodeName, + }) + } + return &pb.ListHTTPDNSRuntimeLogsResponse{ + Logs: pbLogs, + Total: total, + }, nil +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_sandbox.go b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_sandbox.go new file mode 100644 index 0000000..d0b4a86 --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/service_httpdns_sandbox.go @@ -0,0 +1,285 @@ +package httpdns + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/rands" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// HTTPDNSSandboxService HTTPDNS解析测试服务 +type HTTPDNSSandboxService struct { + services.BaseService + pb.UnimplementedHTTPDNSSandboxServiceServer +} + +// nodeResolveResponse 节点返回的 JSON 结构(对齐 EdgeHttpDNS resolve_server.go) +type nodeResolveResponse struct { + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"requestId"` + Data *nodeResolveData `json:"data,omitempty"` +} + +type nodeResolveData struct { + Domain string `json:"domain"` + QType string `json:"qtype"` + TTL int32 `json:"ttl"` + Records []*nodeResolveRecord `json:"records"` + Client *nodeClientInfo `json:"client"` + Summary string `json:"summary"` +} + +type nodeResolveRecord struct { + Type string `json:"type"` + IP string `json:"ip"` + Weight int32 `json:"weight"` + Line string `json:"line"` + Region string `json:"region"` +} + +type nodeClientInfo struct { + IP string `json:"ip"` + Region string `json:"region"` + Carrier string `json:"carrier"` + Country string `json:"country"` +} + +func (this *HTTPDNSSandboxService) TestHTTPDNSResolve(ctx context.Context, req *pb.TestHTTPDNSResolveRequest) (*pb.TestHTTPDNSResolveResponse, error) { + _, userId, err := this.ValidateAdminAndUser(ctx, true) + if err != nil { + return nil, err + } + + if len(req.AppId) == 0 || len(req.Domain) == 0 { + return nil, errors.New("appId 和 domain 不能为空") + } + + app, err := models.SharedHTTPDNSAppDAO.FindEnabledAppWithAppId(this.NullTx(), req.AppId) + if err != nil { + return nil, err + } + if userId > 0 && app != nil && app.UserId != userId { + return nil, errors.New("access denied") + } + if app == nil || !app.IsOn { + return &pb.TestHTTPDNSResolveResponse{ + Code: "APP_NOT_FOUND_OR_DISABLED", + Message: "找不到指定的应用,或该应用已下线", + RequestId: "rid-" + rands.HexString(12), + }, nil + } + // 检查集群是否绑定 + appClusterIds := models.SharedHTTPDNSAppDAO.ReadAppClusterIds(app) + if req.ClusterId > 0 { + var found bool + for _, cid := range appClusterIds { + if cid == req.ClusterId { + found = true + break + } + } + if !found { + return &pb.TestHTTPDNSResolveResponse{ + Code: "APP_CLUSTER_MISMATCH", + Message: "当前应用未绑定到该集群", + RequestId: "rid-" + rands.HexString(12), + }, nil + } + } + + qtype := strings.ToUpper(strings.TrimSpace(req.Qtype)) + if qtype == "" { + qtype = "A" + } + + // 获取集群服务域名 + clusterId := req.ClusterId + if clusterId <= 0 && len(appClusterIds) > 0 { + clusterId = appClusterIds[0] + } + cluster, err := models.SharedHTTPDNSClusterDAO.FindEnabledCluster(this.NullTx(), clusterId) + if err != nil { + return nil, err + } + if cluster == nil { + return &pb.TestHTTPDNSResolveResponse{ + Code: "CLUSTER_NOT_FOUND", + Message: "找不到指定的集群", + RequestId: "rid-" + rands.HexString(12), + }, nil + } + + serviceDomain := strings.TrimSpace(cluster.ServiceDomain) + if len(serviceDomain) == 0 { + return &pb.TestHTTPDNSResolveResponse{ + Code: "NO_SERVICE_DOMAIN", + Message: "该集群未配置服务域名", + RequestId: "rid-" + rands.HexString(12), + }, nil + } + + // 构造请求转发到 EdgeHttpDNS 节点 + secret, err := models.SharedHTTPDNSAppSecretDAO.FindEnabledAppSecret(this.NullTx(), int64(app.Id)) + if err != nil { + return nil, err + } + + port := "443" + if len(cluster.TLSPolicy) > 0 { + var tlsConfig map[string]interface{} + if err := json.Unmarshal(cluster.TLSPolicy, &tlsConfig); err == nil { + if listenRaw, ok := tlsConfig["listen"]; ok && listenRaw != nil { + if data, err := json.Marshal(listenRaw); err == nil { + var listenAddresses []map[string]interface{} + if err := json.Unmarshal(data, &listenAddresses); err == nil { + if len(listenAddresses) > 0 { + if portRange, ok := listenAddresses[0]["portRange"].(string); ok && len(portRange) > 0 { + port = portRange + } + } + } + } + } + } + } + + query := url.Values{} + query.Set("appId", req.AppId) + query.Set("dn", req.Domain) + query.Set("qtype", qtype) + if len(req.ClientIP) > 0 { + query.Set("cip", req.ClientIP) + } + if len(req.Sid) > 0 { + query.Set("sid", req.Sid) + } + if len(req.SdkVersion) > 0 { + query.Set("sdk_version", req.SdkVersion) + } + if len(req.Os) > 0 { + query.Set("os", req.Os) + } + + // 应用开启验签时,沙盒自动生成签名参数,避免测试请求被拒绝 + if secret != nil && secret.SignEnabled { + signSecret := strings.TrimSpace(secret.SignSecret) + if len(signSecret) == 0 { + return &pb.TestHTTPDNSResolveResponse{ + Code: "SIGN_INVALID", + Message: "应用开启了请求验签,但未配置有效加签 Secret", + RequestId: "rid-" + rands.HexString(12), + Domain: req.Domain, + Qtype: qtype, + }, nil + } + + exp := strconv.FormatInt(time.Now().Unix()+300, 10) + nonce := "sandbox-" + rands.HexString(16) + sign := buildSandboxResolveSign(signSecret, req.AppId, req.Domain, qtype, exp, nonce) + + query.Set("exp", exp) + query.Set("nonce", nonce) + query.Set("sign", sign) + } + + resolveURL := "https://" + serviceDomain + ":" + port + "/resolve?" + query.Encode() + + httpClient := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // 沙盒测试环境允许自签名证书 + }, + }, + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, resolveURL, nil) + if err != nil { + return nil, fmt.Errorf("构建请求失败: %w", err) + } + + resp, err := httpClient.Do(httpReq) + if err != nil { + return &pb.TestHTTPDNSResolveResponse{ + Code: "NODE_UNREACHABLE", + Message: "无法连接到 HTTPDNS 节点: " + err.Error(), + RequestId: "rid-" + rands.HexString(12), + Domain: req.Domain, + Qtype: qtype, + }, nil + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return nil, fmt.Errorf("读取节点响应失败: %w", err) + } + + // 解析节点返回的 JSON + var nodeResp nodeResolveResponse + if err := json.Unmarshal(body, &nodeResp); err != nil { + return &pb.TestHTTPDNSResolveResponse{ + Code: "PARSE_ERROR", + Message: "解析节点返回数据失败: " + err.Error(), + RequestId: "rid-" + rands.HexString(12), + Domain: req.Domain, + Qtype: qtype, + }, nil + } + + // 映射节点响应到 protobuf 响应 + pbResp := &pb.TestHTTPDNSResolveResponse{ + Code: nodeResp.Code, + Message: nodeResp.Message, + RequestId: nodeResp.RequestID, + Domain: req.Domain, + Qtype: qtype, + } + + if nodeResp.Data != nil { + pbResp.Ttl = nodeResp.Data.TTL + pbResp.Summary = nodeResp.Data.Summary + + if nodeResp.Data.Client != nil { + pbResp.ClientIP = nodeResp.Data.Client.IP + pbResp.ClientRegion = nodeResp.Data.Client.Region + pbResp.ClientCarrier = nodeResp.Data.Client.Carrier + pbResp.ClientCountry = nodeResp.Data.Client.Country + } + + for _, rec := range nodeResp.Data.Records { + pbResp.Records = append(pbResp.Records, &pb.HTTPDNSResolveRecord{ + Type: rec.Type, + Ip: rec.IP, + Ttl: nodeResp.Data.TTL, + Weight: rec.Weight, + Line: rec.Line, + Region: rec.Region, + }) + } + } + + return pbResp, nil +} + +func buildSandboxResolveSign(signSecret string, appID string, domain string, qtype string, exp string, nonce string) string { + raw := strings.TrimSpace(appID) + "|" + strings.ToLower(strings.TrimSpace(domain)) + "|" + strings.ToUpper(strings.TrimSpace(qtype)) + "|" + strings.TrimSpace(exp) + "|" + strings.TrimSpace(nonce) + mac := hmac.New(sha256.New, []byte(strings.TrimSpace(signSecret))) + _, _ = mac.Write([]byte(raw)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/task_notify.go b/EdgeAPI/internal/rpc/services/httpdns/task_notify.go new file mode 100644 index 0000000..b87301b --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/task_notify.go @@ -0,0 +1,47 @@ +package httpdns + +import ( + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + "github.com/iwind/TeaGo/dbs" +) + +func notifyHTTPDNSClusterTask(tx *dbs.Tx, clusterId int64, taskType models.NodeTaskType) error { + if clusterId <= 0 { + return nil + } + return models.SharedNodeTaskDAO.CreateClusterTask(tx, nodeconfigs.NodeRoleHTTPDNS, clusterId, 0, 0, taskType) +} + +func notifyHTTPDNSAppTasksByApp(tx *dbs.Tx, app *models.HTTPDNSApp, taskType models.NodeTaskType) error { + if app == nil { + return nil + } + + clusterIds := models.SharedHTTPDNSAppDAO.ReadAppClusterIds(app) + notified := map[int64]bool{} + for _, clusterId := range clusterIds { + if clusterId <= 0 || notified[clusterId] { + continue + } + notified[clusterId] = true + err := notifyHTTPDNSClusterTask(tx, clusterId, taskType) + if err != nil { + return err + } + } + + return nil +} + +func notifyHTTPDNSAppTasksByAppDbId(tx *dbs.Tx, appDbId int64, taskType models.NodeTaskType) error { + if appDbId <= 0 { + return nil + } + + app, err := models.SharedHTTPDNSAppDAO.FindEnabledApp(tx, appDbId) + if err != nil { + return err + } + return notifyHTTPDNSAppTasksByApp(tx, app, taskType) +} diff --git a/EdgeAPI/internal/rpc/services/httpdns/user_auth_helpers.go b/EdgeAPI/internal/rpc/services/httpdns/user_auth_helpers.go new file mode 100644 index 0000000..87604b4 --- /dev/null +++ b/EdgeAPI/internal/rpc/services/httpdns/user_auth_helpers.go @@ -0,0 +1,81 @@ +package httpdns + +import ( + "errors" + "strings" + + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/iwind/TeaGo/dbs" +) + +func ensureAppAccess(tx *dbs.Tx, appDbId int64, userId int64) (*models.HTTPDNSApp, error) { + app, err := models.SharedHTTPDNSAppDAO.FindEnabledApp(tx, appDbId) + if err != nil { + return nil, err + } + if app == nil { + return nil, nil + } + if userId > 0 && app.UserId != userId { + return nil, errors.New("access denied") + } + return app, nil +} + +func ensureAppAccessByAppId(tx *dbs.Tx, appId string, userId int64) (*models.HTTPDNSApp, error) { + appId = strings.TrimSpace(appId) + if len(appId) == 0 { + return nil, nil + } + app, err := models.SharedHTTPDNSAppDAO.FindEnabledAppWithAppId(tx, appId) + if err != nil { + return nil, err + } + if app == nil { + return nil, nil + } + if userId > 0 && app.UserId != userId { + return nil, errors.New("access denied") + } + return app, nil +} + +func ensureDomainAccess(tx *dbs.Tx, domainId int64, userId int64) (*models.HTTPDNSDomain, *models.HTTPDNSApp, error) { + domain, err := models.SharedHTTPDNSDomainDAO.FindEnabledDomain(tx, domainId) + if err != nil { + return nil, nil, err + } + if domain == nil { + return nil, nil, nil + } + + app, err := ensureAppAccess(tx, int64(domain.AppId), userId) + if err != nil { + return nil, nil, err + } + if app == nil { + return nil, nil, nil + } + + return domain, app, nil +} + +func ensureRuleAccess(tx *dbs.Tx, ruleId int64, userId int64) (*models.HTTPDNSCustomRule, *models.HTTPDNSApp, error) { + rule, err := models.SharedHTTPDNSCustomRuleDAO.FindEnabledRule(tx, ruleId) + if err != nil { + return nil, nil, err + } + if rule == nil { + return nil, nil, nil + } + + app, err := ensureAppAccess(tx, int64(rule.AppId), userId) + if err != nil { + return nil, nil, err + } + if app == nil { + return nil, nil, nil + } + + return rule, app, nil +} diff --git a/EdgeAPI/internal/rpc/services/nameservers/service_ns_node.go b/EdgeAPI/internal/rpc/services/nameservers/service_ns_node.go index 3cb4a33..ecbbeb6 100644 --- a/EdgeAPI/internal/rpc/services/nameservers/service_ns_node.go +++ b/EdgeAPI/internal/rpc/services/nameservers/service_ns_node.go @@ -12,6 +12,7 @@ import ( "github.com/TeaOSLab/EdgeAPI/internal/goman" "github.com/TeaOSLab/EdgeAPI/internal/installers" "github.com/TeaOSLab/EdgeAPI/internal/rpc/services" + "github.com/TeaOSLab/EdgeAPI/internal/setup" rpcutils "github.com/TeaOSLab/EdgeAPI/internal/rpc/utils" "github.com/TeaOSLab/EdgeCommon/pkg/configutils" "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" @@ -484,6 +485,12 @@ func (this *NSNodeService) DownloadNSNodeInstallationFile(ctx context.Context, r return nil, err } + // 检查自动升级开关 + upgradeConfig, _ := setup.LoadUpgradeConfig() + if upgradeConfig != nil && !upgradeConfig.AutoUpgrade { + return &pb.DownloadNSNodeInstallationFileResponse{}, nil + } + var file = installers.SharedDeployManager.FindNSNodeFile(req.Os, req.Arch) if file == nil { return &pb.DownloadNSNodeInstallationFileResponse{}, nil @@ -738,3 +745,109 @@ func (this *NSNodeService) UpdateNSNodeAPIConfig(ctx context.Context, req *pb.Up return this.Success() } + +// FindAllUpgradeNSNodesWithNSClusterId 列出所有需要升级的NS节点 +func (this *NSNodeService) FindAllUpgradeNSNodesWithNSClusterId(ctx context.Context, req *pb.FindAllUpgradeNSNodesWithNSClusterIdRequest) (*pb.FindAllUpgradeNSNodesWithNSClusterIdResponse, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + + var tx = this.NullTx() + deployFiles := installers.SharedDeployManager.LoadNSNodeFiles() + var result []*pb.FindAllUpgradeNSNodesWithNSClusterIdResponse_NSNodeUpgrade + for _, deployFile := range deployFiles { + nodes, err := models.SharedNSNodeDAO.FindAllLowerVersionNodesWithClusterId(tx, req.NsClusterId, deployFile.OS, deployFile.Arch, deployFile.Version) + if err != nil { + return nil, err + } + for _, node := range nodes { + // 解析状态获取当前版本 + var oldVersion string + if len(node.Status) > 0 { + var statusMap map[string]interface{} + if json.Unmarshal(node.Status, &statusMap) == nil { + if v, ok := statusMap["buildVersion"]; ok { + oldVersion, _ = v.(string) + } + } + } + + // 安装信息 + installStatus, installErr := node.DecodeInstallStatus() + if installErr != nil { + return nil, installErr + } + pbInstallStatus := &pb.NodeInstallStatus{} + if installStatus != nil { + pbInstallStatus = &pb.NodeInstallStatus{ + IsRunning: installStatus.IsRunning, + IsFinished: installStatus.IsFinished, + IsOk: installStatus.IsOk, + Error: installStatus.Error, + ErrorCode: installStatus.ErrorCode, + UpdatedAt: installStatus.UpdatedAt, + } + } + + // 认证信息 + login, loginErr := models.SharedNodeLoginDAO.FindEnabledNodeLoginWithNodeId(tx, nodeconfigs.NodeRoleDNS, int64(node.Id)) + if loginErr != nil { + return nil, loginErr + } + var pbLogin *pb.NodeLogin + if login != nil { + pbLogin = &pb.NodeLogin{ + Id: int64(login.Id), + Name: login.Name, + Type: login.Type, + Params: login.Params, + } + } + + result = append(result, &pb.FindAllUpgradeNSNodesWithNSClusterIdResponse_NSNodeUpgrade{ + NsNode: &pb.NSNode{ + Id: int64(node.Id), + Name: node.Name, + IsOn: node.IsOn, + UniqueId: node.UniqueId, + IsInstalled: node.IsInstalled, + IsUp: node.IsUp, + IsActive: node.IsActive, + StatusJSON: node.Status, + InstallStatus: pbInstallStatus, + NodeLogin: pbLogin, + }, + Os: deployFile.OS, + Arch: deployFile.Arch, + OldVersion: oldVersion, + NewVersion: deployFile.Version, + }) + } + } + return &pb.FindAllUpgradeNSNodesWithNSClusterIdResponse{Nodes: result}, nil +} + +// UpgradeNSNode 升级单个NS节点 +func (this *NSNodeService) UpgradeNSNode(ctx context.Context, req *pb.UpgradeNSNodeRequest) (*pb.RPCSuccess, error) { + _, err := this.ValidateAdmin(ctx) + if err != nil { + return nil, err + } + + var tx = this.NullTx() + + err = models.SharedNSNodeDAO.UpdateNodeIsInstalled(tx, req.NsNodeId, false) + if err != nil { + return nil, err + } + + goman.New(func() { + installErr := installers.SharedNSNodeQueue().InstallNodeProcess(req.NsNodeId, true) + if installErr != nil { + logs.Println("[RPC]upgrade dns node:" + installErr.Error()) + } + }) + + return this.Success() +} diff --git a/EdgeAPI/internal/rpc/services/service_base.go b/EdgeAPI/internal/rpc/services/service_base.go index 66fb9fe..661ba99 100644 --- a/EdgeAPI/internal/rpc/services/service_base.go +++ b/EdgeAPI/internal/rpc/services/service_base.go @@ -83,6 +83,12 @@ func (this *BaseService) ValidateNSNode(ctx context.Context) (nodeId int64, err return } +// ValidateHTTPDNSNode 校验HTTPDNS节点 +func (this *BaseService) ValidateHTTPDNSNode(ctx context.Context) (nodeId int64, err error) { + _, _, nodeId, err = rpcutils.ValidateRequest(ctx, rpcutils.UserTypeHTTPDNS) + return +} + // ValidateUserNode 校验用户节点 func (this *BaseService) ValidateUserNode(ctx context.Context, canRest bool) (userId int64, err error) { // 不允许REST调用 @@ -105,7 +111,7 @@ func (this *BaseService) ValidateAuthorityNode(ctx context.Context) (nodeId int6 func (this *BaseService) ValidateNodeId(ctx context.Context, roles ...rpcutils.UserType) (role rpcutils.UserType, nodeIntId int64, err error) { // 默认包含大部分节点 if len(roles) == 0 { - roles = []rpcutils.UserType{rpcutils.UserTypeNode, rpcutils.UserTypeCluster, rpcutils.UserTypeAdmin, rpcutils.UserTypeUser, rpcutils.UserTypeDNS, rpcutils.UserTypeReport, rpcutils.UserTypeLog, rpcutils.UserTypeAPI} + roles = []rpcutils.UserType{rpcutils.UserTypeNode, rpcutils.UserTypeCluster, rpcutils.UserTypeAdmin, rpcutils.UserTypeUser, rpcutils.UserTypeDNS, rpcutils.UserTypeHTTPDNS, rpcutils.UserTypeReport, rpcutils.UserTypeLog, rpcutils.UserTypeAPI} } if ctx == nil { @@ -191,6 +197,8 @@ func (this *BaseService) ValidateNodeId(ctx context.Context, roles ...rpcutils.U nodeIntId = 0 case rpcutils.UserTypeDNS: nodeIntId, err = models.SharedNSNodeDAO.FindEnabledNodeIdWithUniqueId(nil, nodeId) + case rpcutils.UserTypeHTTPDNS: + nodeIntId, err = models.SharedHTTPDNSNodeDAO.FindEnabledNodeIdWithUniqueId(nil, nodeId) case rpcutils.UserTypeReport: nodeIntId, err = models.SharedReportNodeDAO.FindEnabledNodeIdWithUniqueId(nil, nodeId) case rpcutils.UserTypeAuthority: diff --git a/EdgeAPI/internal/rpc/services/service_node.go b/EdgeAPI/internal/rpc/services/service_node.go index 723f76b..25b82b2 100644 --- a/EdgeAPI/internal/rpc/services/service_node.go +++ b/EdgeAPI/internal/rpc/services/service_node.go @@ -12,6 +12,7 @@ import ( "github.com/TeaOSLab/EdgeAPI/internal/installers" "github.com/TeaOSLab/EdgeAPI/internal/remotelogs" rpcutils "github.com/TeaOSLab/EdgeAPI/internal/rpc/utils" + "github.com/TeaOSLab/EdgeAPI/internal/setup" "github.com/TeaOSLab/EdgeAPI/internal/utils" "github.com/TeaOSLab/EdgeAPI/internal/utils/numberutils" "github.com/TeaOSLab/EdgeCommon/pkg/configutils" @@ -1716,6 +1717,12 @@ func (this *NodeService) DownloadNodeInstallationFile(ctx context.Context, req * return nil, err } + // 检查自动升级开关 + upgradeConfig, _ := setup.LoadUpgradeConfig() + if upgradeConfig != nil && !upgradeConfig.AutoUpgrade { + return &pb.DownloadNodeInstallationFileResponse{}, nil + } + var file = installers.SharedDeployManager.FindNodeFile(req.Os, req.Arch) if file == nil { return &pb.DownloadNodeInstallationFileResponse{}, nil diff --git a/EdgeAPI/internal/rpc/services/service_node_task.go b/EdgeAPI/internal/rpc/services/service_node_task.go index 5651fb7..b7fa3a7 100644 --- a/EdgeAPI/internal/rpc/services/service_node_task.go +++ b/EdgeAPI/internal/rpc/services/service_node_task.go @@ -19,7 +19,7 @@ type NodeTaskService struct { // FindNodeTasks 获取单节点同步任务 func (this *NodeTaskService) FindNodeTasks(ctx context.Context, req *pb.FindNodeTasksRequest) (*pb.FindNodeTasksResponse, error) { - nodeType, nodeId, err := this.ValidateNodeId(ctx, rpcutils.UserTypeNode, rpcutils.UserTypeDNS) + nodeType, nodeId, err := this.ValidateNodeId(ctx, rpcutils.UserTypeNode, rpcutils.UserTypeDNS, rpcutils.UserTypeHTTPDNS) if err != nil { return nil, err } @@ -65,7 +65,7 @@ func (this *NodeTaskService) FindNodeTasks(ctx context.Context, req *pb.FindNode // ReportNodeTaskDone 报告同步任务结果 func (this *NodeTaskService) ReportNodeTaskDone(ctx context.Context, req *pb.ReportNodeTaskDoneRequest) (*pb.RPCSuccess, error) { - _, _, err := this.ValidateNodeId(ctx, rpcutils.UserTypeNode, rpcutils.UserTypeDNS) + _, _, err := this.ValidateNodeId(ctx, rpcutils.UserTypeNode, rpcutils.UserTypeDNS, rpcutils.UserTypeHTTPDNS) if err != nil { return nil, err } diff --git a/EdgeAPI/internal/rpc/services/users/service_user.go b/EdgeAPI/internal/rpc/services/users/service_user.go index afe7ee3..55c4931 100644 --- a/EdgeAPI/internal/rpc/services/users/service_user.go +++ b/EdgeAPI/internal/rpc/services/users/service_user.go @@ -71,7 +71,7 @@ func (this *UserService) UpdateUser(ctx context.Context, req *pb.UpdateUserReque return nil, err } - err = models.SharedUserDAO.UpdateUser(tx, req.UserId, req.Username, req.Password, req.Fullname, req.Mobile, req.Tel, req.Email, req.Remark, req.IsOn, req.NodeClusterId, req.BandwidthAlgo) + err = models.SharedUserDAO.UpdateUser(tx, req.UserId, req.Username, req.Password, req.Fullname, req.Mobile, req.Tel, req.Email, req.Remark, req.IsOn, req.NodeClusterId, req.BandwidthAlgo, req.HttpdnsClusterIdsJSON) if err != nil { return nil, err } @@ -242,6 +242,20 @@ func (this *UserService) FindEnabledUser(ctx context.Context, req *pb.FindEnable } } + // 用户功能列表 + var pbFeatures []*pb.UserFeature + userFeatures, err := models.SharedUserDAO.FindUserFeatures(tx, req.UserId) + if err != nil { + return nil, err + } + for _, f := range userFeatures { + pbFeatures = append(pbFeatures, &pb.UserFeature{ + Name: f.Name, + Code: f.Code, + Description: f.Description, + }) + } + return &pb.FindEnabledUserResponse{ User: &pb.User{ Id: int64(user.Id), @@ -265,6 +279,8 @@ func (this *UserService) FindEnabledUser(ctx context.Context, req *pb.FindEnable BandwidthAlgo: user.BandwidthAlgo, OtpLogin: pbOtpAuth, Lang: user.Lang, + Features: pbFeatures, + HttpdnsClusterIdsJSON: user.HttpdnsClusterIds, }, }, nil } diff --git a/EdgeAPI/internal/rpc/services/users/service_user_ext_plus.go b/EdgeAPI/internal/rpc/services/users/service_user_ext_plus.go index c106a0c..2038d6f 100644 --- a/EdgeAPI/internal/rpc/services/users/service_user_ext_plus.go +++ b/EdgeAPI/internal/rpc/services/users/service_user_ext_plus.go @@ -5,6 +5,7 @@ package users import ( "context" + "encoding/json" "errors" "github.com/TeaOSLab/EdgeAPI/internal/db/models" "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" @@ -142,13 +143,24 @@ func (this *UserService) RegisterUser(ctx context.Context, req *pb.RegisterUserR return errors.New("the username exists already") } + features := registerConfig.Features + // 创建用户 - userId, err := models.SharedUserDAO.CreateUser(tx, req.Username, req.Password, req.Fullname, req.Mobile, "", req.Email, "", req.Source, registerConfig.ClusterId, registerConfig.Features, req.Ip, !registerConfig.RequireVerification) + userId, err := models.SharedUserDAO.CreateUser(tx, req.Username, req.Password, req.Fullname, req.Mobile, "", req.Email, "", req.Source, registerConfig.ClusterId, features, req.Ip, !registerConfig.RequireVerification) if err != nil { return err } createdUserId = userId + // 自动关联默认 HTTPDNS 集群 + if registerConfig.HTTPDNSIsOn && len(registerConfig.HTTPDNSDefaultClusterIds) > 0 { + httpdnsJSON, _ := json.Marshal(registerConfig.HTTPDNSDefaultClusterIds) + err = models.SharedUserDAO.UpdateUserHttpdnsClusterIds(tx, userId, httpdnsJSON) + if err != nil { + return err + } + } + // 发送激活邮件 if len(req.Email) > 0 && registerConfig.EmailVerification.IsOn { _, err := models.SharedUserEmailVerificationDAO.CreateVerification(tx, userId, req.Email) diff --git a/EdgeAPI/internal/rpc/utils/utils.go b/EdgeAPI/internal/rpc/utils/utils.go index 6ea6906..cb9f459 100644 --- a/EdgeAPI/internal/rpc/utils/utils.go +++ b/EdgeAPI/internal/rpc/utils/utils.go @@ -16,6 +16,7 @@ const ( UserTypeCluster = "cluster" UserTypeStat = "stat" UserTypeDNS = "dns" + UserTypeHTTPDNS = "httpdns" UserTypeLog = "log" UserTypeAPI = "api" UserTypeAuthority = "authority" diff --git a/EdgeAPI/internal/rpc/utils/utils_ext.go b/EdgeAPI/internal/rpc/utils/utils_ext.go index aa8c0aa..a1ae946 100644 --- a/EdgeAPI/internal/rpc/utils/utils_ext.go +++ b/EdgeAPI/internal/rpc/utils/utils_ext.go @@ -142,6 +142,16 @@ func ValidateRequest(ctx context.Context, userTypes ...UserType) (userType UserT return UserTypeUser, 0, 0, errors.New("context: not found node with id '" + nodeId + "'") } resultNodeId = nodeIntId + case UserTypeHTTPDNS: + nodeIntId, err := models.SharedHTTPDNSNodeDAO.FindEnabledNodeIdWithUniqueId(nil, nodeId) + if err != nil { + return UserTypeHTTPDNS, nodeIntId, 0, errors.New("context: " + err.Error()) + } + if nodeIntId <= 0 { + return UserTypeHTTPDNS, nodeIntId, 0, errors.New("context: not found node with id '" + nodeId + "'") + } + nodeUserId = nodeIntId + resultNodeId = nodeIntId } if nodeUserId > 0 { diff --git a/EdgeAPI/internal/rpc/utils/utils_ext_plus.go b/EdgeAPI/internal/rpc/utils/utils_ext_plus.go index 49d8784..8a9e133 100644 --- a/EdgeAPI/internal/rpc/utils/utils_ext_plus.go +++ b/EdgeAPI/internal/rpc/utils/utils_ext_plus.go @@ -171,6 +171,16 @@ func ValidateRequest(ctx context.Context, userTypes ...UserType) (userType UserT } nodeUserId = nodeIntId resultNodeId = nodeIntId + case UserTypeHTTPDNS: + nodeIntId, err := models.SharedHTTPDNSNodeDAO.FindEnabledNodeIdWithUniqueId(nil, nodeId) + if err != nil { + return UserTypeHTTPDNS, nodeIntId, 0, errors.New("context: " + err.Error()) + } + if nodeIntId <= 0 { + return UserTypeHTTPDNS, nodeIntId, 0, errors.New("context: not found node with id '" + nodeId + "'") + } + nodeUserId = nodeIntId + resultNodeId = nodeIntId case UserTypeReport: nodeIntId, err := models.SharedReportNodeDAO.FindEnabledNodeIdWithUniqueId(nil, nodeId) if err != nil { diff --git a/EdgeAPI/internal/setup/clickhouse_upgrade.go b/EdgeAPI/internal/setup/clickhouse_upgrade.go new file mode 100644 index 0000000..e7690d4 --- /dev/null +++ b/EdgeAPI/internal/setup/clickhouse_upgrade.go @@ -0,0 +1,132 @@ +package setup + +import ( + "context" + "strings" + "time" + + "github.com/TeaOSLab/EdgeAPI/internal/clickhouse" +) + +// EnsureClickHouseTables 自动确保日志相关 ClickHouse 表存在。 +// 仅做 CREATE TABLE IF NOT EXISTS,不会覆盖已有表结构。 +func EnsureClickHouseTables() error { + client := clickhouse.NewClient() + if !client.IsConfigured() { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + sqls := []string{ + `CREATE TABLE IF NOT EXISTS logs_ingest +( + timestamp DateTime CODEC(DoubleDelta, ZSTD(1)), + node_id UInt64, + cluster_id UInt64, + server_id UInt64, + host LowCardinality(String), + ip String, + method LowCardinality(String), + path String CODEC(ZSTD(1)), + status UInt16, + bytes_in UInt64 CODEC(Delta, ZSTD(1)), + bytes_out UInt64 CODEC(Delta, ZSTD(1)), + cost_ms UInt32 CODEC(Delta, ZSTD(1)), + ua String CODEC(ZSTD(1)), + referer String CODEC(ZSTD(1)), + log_type LowCardinality(String), + trace_id String, + firewall_policy_id UInt64 DEFAULT 0, + firewall_rule_group_id UInt64 DEFAULT 0, + firewall_rule_set_id UInt64 DEFAULT 0, + firewall_rule_id UInt64 DEFAULT 0, + request_headers String DEFAULT '' CODEC(ZSTD(3)), + request_body String DEFAULT '' CODEC(ZSTD(3)), + response_headers String DEFAULT '' CODEC(ZSTD(3)), + response_body String DEFAULT '' CODEC(ZSTD(3)), + INDEX idx_trace_id trace_id TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_ip ip TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_host host TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, + INDEX idx_fw_policy firewall_policy_id TYPE minmax GRANULARITY 4, + INDEX idx_status status TYPE minmax GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toYYYYMMDD(timestamp) +ORDER BY (timestamp, node_id, server_id, trace_id) +SETTINGS index_granularity = 8192`, + `CREATE TABLE IF NOT EXISTS dns_logs_ingest +( + timestamp DateTime CODEC(DoubleDelta, ZSTD(1)), + request_id String, + node_id UInt64, + cluster_id UInt64, + domain_id UInt64, + record_id UInt64, + remote_addr String, + question_name String, + question_type LowCardinality(String), + record_name String, + record_type LowCardinality(String), + record_value String, + networking LowCardinality(String), + is_recursive UInt8, + error String CODEC(ZSTD(1)), + ns_route_codes Array(String), + content_json String DEFAULT '' CODEC(ZSTD(3)), + INDEX idx_request_id request_id TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_remote_addr remote_addr TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_question_name question_name TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, + INDEX idx_domain_id domain_id TYPE minmax GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY toYYYYMMDD(timestamp) +ORDER BY (timestamp, request_id, node_id) +SETTINGS index_granularity = 8192`, + `CREATE TABLE IF NOT EXISTS httpdns_access_logs_ingest +( + request_id String, + cluster_id UInt64, + node_id UInt64, + app_id String, + app_name String, + domain String, + qtype LowCardinality(String), + client_ip String, + client_region String, + carrier String, + sdk_version String, + os LowCardinality(String), + result_ips String, + status LowCardinality(String), + error_code String, + cost_ms UInt32, + created_at UInt64, + day String, + summary String CODEC(ZSTD(1)), + INDEX idx_request_id request_id TYPE bloom_filter(0.01) GRANULARITY 4, + INDEX idx_cluster_id cluster_id TYPE minmax GRANULARITY 4, + INDEX idx_node_id node_id TYPE minmax GRANULARITY 4, + INDEX idx_app_id app_id TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, + INDEX idx_domain domain TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, + INDEX idx_status status TYPE minmax GRANULARITY 4 +) +ENGINE = MergeTree +PARTITION BY day +ORDER BY (day, created_at, request_id, node_id) +SETTINGS index_granularity = 8192`, + } + + for _, sql := range sqls { + stmt := strings.TrimSpace(sql) + if len(stmt) == 0 { + continue + } + if err := client.Execute(ctx, stmt); err != nil { + return err + } + } + + return nil +} diff --git a/EdgeAPI/internal/setup/sql_upgrade.go b/EdgeAPI/internal/setup/sql_upgrade.go index ff0e77b..40b789f 100644 --- a/EdgeAPI/internal/setup/sql_upgrade.go +++ b/EdgeAPI/internal/setup/sql_upgrade.go @@ -110,6 +110,12 @@ var upgradeFuncs = []*upgradeVersion{ { "1.4.4", upgradeV1_4_4, }, + { + "1.4.8", upgradeV1_4_8, + }, + { + "1.4.9", upgradeV1_4_9, + }, } // UpgradeSQLData 升级SQL数据 @@ -269,14 +275,14 @@ func upgradeV0_0_10(db *dbs.DB) error { // v0.2.5 func upgradeV0_2_5(db *dbs.DB) error { - // 更新用户 + // 鏇存柊鐢ㄦ埛 _, err := db.Exec("UPDATE edgeUsers SET day=FROM_UNIXTIME(createdAt,'%Y%m%d') WHERE day IS NULL OR LENGTH(day)=0") if err != nil { return err } - // 更新防火墙规则 - ones, _, err := db.FindOnes("SELECT id, actions, action, actionOptions FROM edgeHTTPFirewallRuleSets WHERE actions IS NULL OR LENGTH(actions)=0") + // 更新防火墙规则 + ones, _, err := db.FindOnes("SELECT id, actions, action, actionOptions FROM edgeHTTPFirewallRuleSets WHERE actions IS NULL OR LENGTH(actions)=0") if err != nil { return err } @@ -309,8 +315,8 @@ func upgradeV0_2_5(db *dbs.DB) error { // v0.3.0 func upgradeV0_3_0(db *dbs.DB) error { - // 升级健康检查 - ones, _, err := db.FindOnes("SELECT id,healthCheck FROM edgeNodeClusters WHERE state=1") + // 升级健康检查 + ones, _, err := db.FindOnes("SELECT id,healthCheck FROM edgeNodeClusters WHERE state=1") if err != nil { return err } @@ -342,11 +348,10 @@ func upgradeV0_3_0(db *dbs.DB) error { // v0.3.1 func upgradeV0_3_1(db *dbs.DB) error { - // 清空域名统计,已使用分表代替 - // 因为可能有权限问题,所以我们忽略错误 - _, _ = db.Exec("TRUNCATE table edgeServerDomainHourlyStats") + // 娓呯┖鍩熷悕缁熻锛屽凡浣跨敤鍒嗚〃浠f浛 + // 鍥犱负鍙兘鏈夋潈闄愰棶棰橈紝鎵€浠ユ垜浠拷鐣ラ敊璇? _, _ = db.Exec("TRUNCATE table edgeServerDomainHourlyStats") - // 升级APIToken + // 鍗囩骇APIToken ones, _, err := db.FindOnes("SELECT uniqueId,secret FROM edgeNodeClusters") if err != nil { return err @@ -374,9 +379,9 @@ func upgradeV0_3_2(db *dbs.DB) error { // gzip => compression type HTTPGzipRef struct { - IsPrior bool `yaml:"isPrior" json:"isPrior"` // 是否覆盖 - IsOn bool `yaml:"isOn" json:"isOn"` // 是否开启 - GzipId int64 `yaml:"gzipId" json:"gzipId"` // 使用的配置ID + IsPrior bool `yaml:"isPrior" json:"isPrior"` // 鏄惁瑕嗙洊 + IsOn bool `yaml:"isOn" json:"isOn"` // 是否开启 + GzipId int64 `yaml:"gzipId" json:"gzipId"` // 使用的配置ID } webOnes, _, err := db.FindOnes("SELECT id, gzip FROM edgeHTTPWebs WHERE gzip IS NOT NULL AND compression IS NULL") @@ -458,7 +463,7 @@ func upgradeV0_3_2(db *dbs.DB) error { } } - // 更新服务端口 + // 鏇存柊鏈嶅姟绔彛 var serverDAO = models.NewServerDAO() ones, err := serverDAO.Query(nil). ResultPk(). @@ -479,14 +484,14 @@ func upgradeV0_3_2(db *dbs.DB) error { // v0.3.3 func upgradeV0_3_3(db *dbs.DB) error { - // 升级CC请求数Code - _, err := db.Exec("UPDATE edgeHTTPFirewallRuleSets SET code='8002' WHERE name='CC请求数' AND code='8001'") + // 鍗囩骇CC璇锋眰鏁癈ode + _, err := db.Exec("UPDATE edgeHTTPFirewallRuleSets SET code='8002' WHERE name='CC璇锋眰鏁? AND code='8001'") if err != nil { return err } - // 清除节点 - // 删除7天以前的info日志 + // 娓呴櫎鑺傜偣 + // 鍒犻櫎7澶╀互鍓嶇殑info鏃ュ織 err = models.NewNodeLogDAO().DeleteExpiredLogsWithLevel(nil, "info", 7) if err != nil { return err @@ -497,13 +502,13 @@ func upgradeV0_3_3(db *dbs.DB) error { // v0.3.7 func upgradeV0_3_7(db *dbs.DB) error { - // 修改所有edgeNodeGrants中的su为0 + // 淇敼鎵€鏈塭dgeNodeGrants涓殑su涓? _, err := db.Exec("UPDATE edgeNodeGrants SET su=0 WHERE su=1") if err != nil { return err } - // WAF预置分组 + // WAF棰勭疆鍒嗙粍 _, err = db.Exec("UPDATE edgeHTTPFirewallRuleGroups SET isTemplate=1 WHERE LENGTH(code)>0") if err != nil { return err @@ -514,7 +519,7 @@ func upgradeV0_3_7(db *dbs.DB) error { // v0.4.0 func upgradeV0_4_0(db *dbs.DB) error { - // 升级SYN Flood配置 + // 鍗囩骇SYN Flood閰嶇疆 synFloodJSON, err := json.Marshal(firewallconfigs.NewSYNFloodConfig()) if err == nil { _, err := db.Exec("UPDATE edgeHTTPFirewallPolicies SET synFlood=? WHERE synFlood IS NULL AND state=1", string(synFloodJSON)) @@ -528,13 +533,13 @@ func upgradeV0_4_0(db *dbs.DB) error { // v0.4.1 func upgradeV0_4_1(db *dbs.DB) error { - // 升级 servers.lastUserPlanId + // 鍗囩骇 servers.lastUserPlanId _, err := db.Exec("UPDATE edgeServers SET lastUserPlanId=userPlanId WHERE userPlanId>0") if err != nil { return err } - // 执行域名统计清理 + // 鎵ц鍩熷悕缁熻娓呯悊 err = stats.NewServerDomainHourlyStatDAO().CleanDays(nil, 7) if err != nil { return err @@ -545,7 +550,7 @@ func upgradeV0_4_1(db *dbs.DB) error { // v0.4.5 func upgradeV0_4_5(db *dbs.DB) error { - // 升级访问日志自动分表 + // 鍗囩骇璁块棶鏃ュ織鑷姩鍒嗚〃 { var dao = models.NewSysSettingDAO() valueJSON, err := dao.ReadSetting(nil, systemconfigs.SettingCodeAccessLogQueue) @@ -569,7 +574,7 @@ func upgradeV0_4_5(db *dbs.DB) error { } } - // 升级一个防SQL注入规则 + // 鍗囩骇涓€涓槻SQL娉ㄥ叆瑙勫垯 { ones, _, err := db.FindOnes(`SELECT id FROM edgeHTTPFirewallRules WHERE value=?`, "(updatexml|extractvalue|ascii|ord|char|chr|count|concat|rand|floor|substr|length|len|user|database|benchmark|analyse)\\s*\\(") if err != nil { @@ -589,7 +594,7 @@ func upgradeV0_4_5(db *dbs.DB) error { // v0.4.7 func upgradeV0_4_7(db *dbs.DB) error { - // 升级 edgeServers 中的 plainServerNames + // 鍗囩骇 edgeServers 涓殑 plainServerNames { ones, _, err := db.FindOnes("SELECT id,serverNames FROM edgeServers WHERE state=1") if err != nil { @@ -621,7 +626,7 @@ func upgradeV0_4_7(db *dbs.DB) error { // v0.4.8 func upgradeV0_4_8(db *dbs.DB) error { - // 设置edgeIPLists中的serverId + // 璁剧疆edgeIPLists涓殑serverId { firewallPolicyOnes, _, err := db.FindOnes("SELECT inbound,serverId FROM edgeHTTPFirewallPolicies WHERE serverId>0") if err != nil { @@ -673,7 +678,7 @@ func upgradeV0_4_8(db *dbs.DB) error { // v0.4.11 func upgradeV0_4_11(db *dbs.DB) error { - // 升级ns端口 + // 鍗囩骇ns绔彛 { // TCP { @@ -751,19 +756,19 @@ func upgradeV1_2_1(db *dbs.DB) error { // 1.2.10 func upgradeV1_2_10(db *dbs.DB) error { { - type OldGlobalConfig struct { - // HTTP & HTTPS相关配置 - HTTPAll struct { - DomainAuditingIsOn bool `yaml:"domainAuditingIsOn" json:"domainAuditingIsOn"` // 域名是否需要审核 - DomainAuditingPrompt string `yaml:"domainAuditingPrompt" json:"domainAuditingPrompt"` // 域名审核的提示 - } `yaml:"httpAll" json:"httpAll"` + type OldGlobalConfig struct { + // HTTP & HTTPS鐩稿叧閰嶇疆 + HTTPAll struct { + DomainAuditingIsOn bool `yaml:"domainAuditingIsOn" json:"domainAuditingIsOn"` // 域名是否需要审核 + DomainAuditingPrompt string `yaml:"domainAuditingPrompt" json:"domainAuditingPrompt"` // 域名审核提示 + } `yaml:"httpAll" json:"httpAll"` - TCPAll struct { - PortRangeMin int `yaml:"portRangeMin" json:"portRangeMin"` // 最小端口 - PortRangeMax int `yaml:"portRangeMax" json:"portRangeMax"` // 最大端口 - DenyPorts []int `yaml:"denyPorts" json:"denyPorts"` // 禁止使用的端口 - } `yaml:"tcpAll" json:"tcpAll"` - } + TCPAll struct { + PortRangeMin int `yaml:"portRangeMin" json:"portRangeMin"` // 最小端口 + PortRangeMax int `yaml:"portRangeMax" json:"portRangeMax"` // 最大端口 + DenyPorts []int `yaml:"denyPorts" json:"denyPorts"` // 禁止端口 + } `yaml:"tcpAll" json:"tcpAll"` + } globalConfigValue, err := db.FindCol(0, "SELECT value FROM edgeSysSettings WHERE code='serverGlobalConfig'") if err != nil { @@ -1023,7 +1028,7 @@ func upgradeV1_3_2(db *dbs.DB) error { } } - err = addRuleToGroup(ruleGroup.GetInt64("id"), "7010", "SQL注入检测", []*firewallconfigs.HTTPFirewallActionConfig{ + err = addRuleToGroup(ruleGroup.GetInt64("id"), "7010", "SQL注入检测", []*firewallconfigs.HTTPFirewallActionConfig{ { Code: firewallconfigs.HTTPFirewallActionPage, Options: maps.Map{"status": 403, "body": ""}, @@ -1131,7 +1136,7 @@ func upgradeV1_3_2(db *dbs.DB) error { } } - err = addRuleToGroup(ruleGroup.GetInt64("id"), "1010", "XSS攻击检测", []*firewallconfigs.HTTPFirewallActionConfig{ + err = addRuleToGroup(ruleGroup.GetInt64("id"), "1010", "XSS攻击检测", []*firewallconfigs.HTTPFirewallActionConfig{ { Code: firewallconfigs.HTTPFirewallActionPage, Options: maps.Map{"status": 403, "body": ""}, @@ -1233,8 +1238,8 @@ func upgradeV1_3_4(db *dbs.DB) error { // 1.4.4 func upgradeV1_4_4(db *dbs.DB) error { - // 检查 encryption 字段是否已存在 - col, err := db.FindCol(0, "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='edgeHTTPWebs' AND COLUMN_NAME='encryption'") + // 检查 encryption 字段是否已存在 + col, err := db.FindCol(0, "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='edgeHTTPWebs' AND COLUMN_NAME='encryption'") if err != nil { return err } @@ -1253,3 +1258,58 @@ func upgradeV1_4_4(db *dbs.DB) error { return nil } + +// 1.4.8 +func upgradeV1_4_8(db *dbs.DB) error { + err := createHTTPDNSTables(db) + if err != nil { + return err + } + + // edgeUsers: 增加 httpdnsClusterIds 字段 + _, alterErr := db.Exec("ALTER TABLE `edgeUsers` ADD COLUMN `httpdnsClusterIds` text DEFAULT NULL") + if alterErr != nil { + if strings.Contains(alterErr.Error(), "Duplicate column") { + return nil + } + return alterErr + } + return nil +} + +// 1.4.9 +func upgradeV1_4_9(db *dbs.DB) error { + _, err := db.Exec("ALTER TABLE `edgeHTTPDNSClusters` ALTER COLUMN `installDir` SET DEFAULT '/root/edge-httpdns'") + if err != nil { + return err + } + + _, err = db.Exec("ALTER TABLE `edgeHTTPDNSNodes` ALTER COLUMN `installDir` SET DEFAULT '/root/edge-httpdns'") + if err != nil { + return err + } + + return nil +} + +func createHTTPDNSTables(db *dbs.DB) error { + sqls := []string{ + "CREATE TABLE IF NOT EXISTS `edgeHTTPDNSClusters` (`id` bigint unsigned NOT NULL AUTO_INCREMENT,`name` varchar(255) DEFAULT NULL,`isOn` tinyint unsigned DEFAULT '1',`isDefault` tinyint unsigned DEFAULT '0',`serviceDomain` varchar(255) DEFAULT NULL,`defaultTTL` int unsigned DEFAULT '30',`fallbackTimeoutMs` int unsigned DEFAULT '300',`installDir` varchar(255) DEFAULT '/root/edge-httpdns',`tlsPolicy` json DEFAULT NULL,`autoRemoteStart` tinyint unsigned DEFAULT '0',`accessLogIsOn` tinyint unsigned DEFAULT '0',`timeZone` varchar(128) NOT NULL DEFAULT '',`createdAt` bigint unsigned DEFAULT '0',`updatedAt` bigint unsigned DEFAULT '0',`state` tinyint unsigned DEFAULT '1',PRIMARY KEY (`id`),KEY `name` (`name`),KEY `isDefault` (`isDefault`),KEY `state` (`state`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HTTPDNS集群配置表(默认TTL、回退超时、服务域名等)'", + "CREATE TABLE IF NOT EXISTS `edgeHTTPDNSNodes` (`id` bigint unsigned NOT NULL AUTO_INCREMENT,`clusterId` bigint unsigned DEFAULT '0',`name` varchar(255) DEFAULT NULL,`isOn` tinyint unsigned DEFAULT '1',`isUp` tinyint unsigned DEFAULT '0',`isInstalled` tinyint unsigned DEFAULT '0',`isActive` tinyint unsigned DEFAULT '0',`uniqueId` varchar(64) DEFAULT NULL,`secret` varchar(64) DEFAULT NULL,`installDir` varchar(255) DEFAULT '/root/edge-httpdns',`status` json DEFAULT NULL,`installStatus` json DEFAULT NULL,`createdAt` bigint unsigned DEFAULT '0',`updatedAt` bigint unsigned DEFAULT '0',`state` tinyint unsigned DEFAULT '1',PRIMARY KEY (`id`),UNIQUE KEY `uniqueId` (`uniqueId`),KEY `clusterId` (`clusterId`),KEY `state` (`state`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HTTPDNS节点表(节点基础信息与运行状态)'", + "CREATE TABLE IF NOT EXISTS `edgeHTTPDNSApps` (`id` bigint unsigned NOT NULL AUTO_INCREMENT,`name` varchar(255) DEFAULT NULL,`appId` varchar(64) DEFAULT NULL,`isOn` tinyint unsigned DEFAULT '1',`clusterIdsJSON` text DEFAULT NULL,`sniMode` varchar(64) DEFAULT 'fixed_hide',`userId` bigint unsigned DEFAULT '0',`createdAt` bigint unsigned DEFAULT '0',`updatedAt` bigint unsigned DEFAULT '0',`state` tinyint unsigned DEFAULT '1',PRIMARY KEY (`id`),UNIQUE KEY `appId` (`appId`),KEY `name` (`name`),KEY `userId` (`userId`),KEY `state` (`state`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HTTPDNS应用表(应用与集群绑定关系)'", + "CREATE TABLE IF NOT EXISTS `edgeHTTPDNSAppSecrets` (`id` bigint unsigned NOT NULL AUTO_INCREMENT,`appId` bigint unsigned DEFAULT '0',`signEnabled` tinyint unsigned DEFAULT '0',`signSecret` varchar(255) DEFAULT NULL,`signUpdatedAt` bigint unsigned DEFAULT '0',`updatedAt` bigint unsigned DEFAULT '0',`state` tinyint unsigned DEFAULT '1',PRIMARY KEY (`id`),UNIQUE KEY `appId` (`appId`),KEY `state` (`state`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HTTPDNS应用密钥表(请求验签开关与加签Secret)'", + "CREATE TABLE IF NOT EXISTS `edgeHTTPDNSDomains` (`id` bigint unsigned NOT NULL AUTO_INCREMENT,`appId` bigint unsigned DEFAULT '0',`domain` varchar(255) DEFAULT NULL,`isOn` tinyint unsigned DEFAULT '1',`createdAt` bigint unsigned DEFAULT '0',`updatedAt` bigint unsigned DEFAULT '0',`state` tinyint unsigned DEFAULT '1',PRIMARY KEY (`id`),UNIQUE KEY `appId_domain` (`appId`,`domain`),KEY `domain` (`domain`),KEY `state` (`state`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HTTPDNS应用域名表(应用绑定的业务域名)'", + "CREATE TABLE IF NOT EXISTS `edgeHTTPDNSCustomRules` (`id` bigint unsigned NOT NULL AUTO_INCREMENT,`appId` bigint unsigned DEFAULT '0',`domainId` bigint unsigned DEFAULT '0',`ruleName` varchar(255) DEFAULT NULL,`lineScope` varchar(64) DEFAULT NULL,`lineCarrier` varchar(64) DEFAULT NULL,`lineRegion` varchar(64) DEFAULT NULL,`lineProvince` varchar(64) DEFAULT NULL,`lineContinent` varchar(64) DEFAULT NULL,`lineCountry` varchar(128) DEFAULT NULL,`ttl` int unsigned DEFAULT '30',`isOn` tinyint unsigned DEFAULT '1',`priority` int unsigned DEFAULT '0',`updatedAt` bigint unsigned DEFAULT '0',`state` tinyint unsigned DEFAULT '1',PRIMARY KEY (`id`),KEY `domainId_isOn_priority` (`domainId`,`isOn`,`priority`),KEY `state` (`state`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HTTPDNS自定义解析规则表(按线路/地域匹配)'", + "CREATE TABLE IF NOT EXISTS `edgeHTTPDNSCustomRuleRecords` (`id` bigint unsigned NOT NULL AUTO_INCREMENT,`ruleId` bigint unsigned DEFAULT '0',`recordType` varchar(32) DEFAULT NULL,`recordValue` varchar(255) DEFAULT NULL,`weight` int unsigned DEFAULT '0',`sort` int unsigned DEFAULT '0',`state` tinyint unsigned DEFAULT '1',PRIMARY KEY (`id`),KEY `ruleId` (`ruleId`),KEY `state` (`state`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HTTPDNS自定义规则记录值表(A/AAAA及权重)'", + "CREATE TABLE IF NOT EXISTS `edgeHTTPDNSAccessLogs` (`id` bigint unsigned NOT NULL AUTO_INCREMENT,`requestId` varchar(128) DEFAULT NULL,`clusterId` bigint unsigned DEFAULT '0',`nodeId` bigint unsigned DEFAULT '0',`appId` varchar(64) DEFAULT NULL,`appName` varchar(255) DEFAULT NULL,`domain` varchar(255) DEFAULT NULL,`qtype` varchar(16) DEFAULT NULL,`clientIP` varchar(64) DEFAULT NULL,`clientRegion` varchar(255) DEFAULT NULL,`carrier` varchar(128) DEFAULT NULL,`sdkVersion` varchar(64) DEFAULT NULL,`os` varchar(64) DEFAULT NULL,`resultIPs` text,`status` varchar(32) DEFAULT NULL,`errorCode` varchar(64) DEFAULT NULL,`costMs` int unsigned DEFAULT '0',`createdAt` bigint unsigned DEFAULT '0',`day` varchar(8) DEFAULT NULL,`summary` text,PRIMARY KEY (`id`),UNIQUE KEY `requestId_nodeId` (`requestId`,`nodeId`),KEY `day_cluster_node_domain_status_createdAt` (`day`,`clusterId`,`nodeId`,`domain`,`status`,`createdAt`),KEY `appId` (`appId`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HTTPDNS访问日志表(解析请求与结果)'", + "CREATE TABLE IF NOT EXISTS `edgeHTTPDNSRuntimeLogs` (`id` bigint unsigned NOT NULL AUTO_INCREMENT,`clusterId` bigint unsigned DEFAULT '0',`nodeId` bigint unsigned DEFAULT '0',`level` varchar(32) DEFAULT NULL,`type` varchar(64) DEFAULT NULL,`module` varchar(64) DEFAULT NULL,`description` text,`count` bigint unsigned DEFAULT '1',`requestId` varchar(128) DEFAULT NULL,`createdAt` bigint unsigned DEFAULT '0',`day` varchar(8) DEFAULT NULL,PRIMARY KEY (`id`),KEY `day_cluster_node_level_createdAt` (`day`,`clusterId`,`nodeId`,`level`,`createdAt`),KEY `requestId` (`requestId`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HTTPDNS运行日志表(节点运行与异常日志)'", + } + for _, sql := range sqls { + if _, err := db.Exec(sql); err != nil { + return err + } + } + return nil +} + + diff --git a/EdgeAPI/internal/setup/upgrade_config.go b/EdgeAPI/internal/setup/upgrade_config.go new file mode 100644 index 0000000..99ef559 --- /dev/null +++ b/EdgeAPI/internal/setup/upgrade_config.go @@ -0,0 +1,45 @@ +package setup + +import ( + "encoding/json" + "sync" + "time" + + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeCommon/pkg/systemconfigs" +) + +var ( + sharedUpgradeConfig *systemconfigs.UpgradeConfig + sharedUpgradeConfigTime time.Time + sharedUpgradeConfigMu sync.Mutex +) + +const upgradeConfigTTL = 5 * time.Minute + +// LoadUpgradeConfig 读取升级配置(带5分钟内存缓存) +func LoadUpgradeConfig() (*systemconfigs.UpgradeConfig, error) { + sharedUpgradeConfigMu.Lock() + defer sharedUpgradeConfigMu.Unlock() + + if sharedUpgradeConfig != nil && time.Since(sharedUpgradeConfigTime) < upgradeConfigTTL { + return sharedUpgradeConfig, nil + } + + valueJSON, err := models.SharedSysSettingDAO.ReadSetting(nil, systemconfigs.SettingCodeUpgradeConfig) + if err != nil { + return nil, err + } + + config := systemconfigs.NewUpgradeConfig() + if len(valueJSON) > 0 { + err = json.Unmarshal(valueJSON, config) + if err != nil { + return config, nil + } + } + + sharedUpgradeConfig = config + sharedUpgradeConfigTime = time.Now() + return config, nil +} diff --git a/EdgeAPI/internal/tasks/httpdns_node_monitor_task.go b/EdgeAPI/internal/tasks/httpdns_node_monitor_task.go new file mode 100644 index 0000000..949d91b --- /dev/null +++ b/EdgeAPI/internal/tasks/httpdns_node_monitor_task.go @@ -0,0 +1,107 @@ +package tasks + +import ( + "time" + + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/goman" + "github.com/TeaOSLab/EdgeAPI/internal/installers" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + "github.com/iwind/TeaGo/dbs" +) + +func init() { + dbs.OnReadyDone(func() { + goman.New(func() { + NewHTTPDNSNodeMonitorTask(1 * time.Minute).Start() + }) + }) +} + +type httpdnsNodeStartingTry struct { + count int + timestamp int64 +} + +// HTTPDNSNodeMonitorTask monitors HTTPDNS node activity and optionally tries to start offline nodes. +type HTTPDNSNodeMonitorTask struct { + BaseTask + + ticker *time.Ticker + + recoverMap map[int64]*httpdnsNodeStartingTry // nodeId => retry info +} + +func NewHTTPDNSNodeMonitorTask(duration time.Duration) *HTTPDNSNodeMonitorTask { + return &HTTPDNSNodeMonitorTask{ + ticker: time.NewTicker(duration), + recoverMap: map[int64]*httpdnsNodeStartingTry{}, + } +} + +func (t *HTTPDNSNodeMonitorTask) Start() { + for range t.ticker.C { + if err := t.Loop(); err != nil { + t.logErr("HTTPDNS_NODE_MONITOR", err.Error()) + } + } +} + +func (t *HTTPDNSNodeMonitorTask) Loop() error { + // only run on primary api node + if !t.IsPrimaryNode() { + return nil + } + + clusters, err := models.SharedHTTPDNSClusterDAO.FindAllEnabledClusters(nil) + if err != nil { + return err + } + + for _, cluster := range clusters { + if cluster == nil || !cluster.IsOn || !cluster.AutoRemoteStart { + continue + } + clusterID := int64(cluster.Id) + inactiveNodes, err := models.SharedHTTPDNSNodeDAO.FindAllInactiveNodesWithClusterId(nil, clusterID) + if err != nil { + return err + } + if len(inactiveNodes) == 0 { + continue + } + + nodeQueue := installers.NewHTTPDNSNodeQueue() + for _, node := range inactiveNodes { + nodeID := int64(node.Id) + tryInfo, ok := t.recoverMap[nodeID] + if !ok { + tryInfo = &httpdnsNodeStartingTry{ + count: 1, + timestamp: time.Now().Unix(), + } + t.recoverMap[nodeID] = tryInfo + } else { + if tryInfo.count >= 3 { + if tryInfo.timestamp+10*60 > time.Now().Unix() { + continue + } + tryInfo.timestamp = time.Now().Unix() + tryInfo.count = 0 + } + tryInfo.count++ + } + + err = nodeQueue.StartNode(nodeID) + if err != nil { + if !installers.IsGrantError(err) { + _ = models.SharedNodeLogDAO.CreateLog(nil, nodeconfigs.NodeRoleHTTPDNS, nodeID, 0, 0, models.LevelError, "NODE", "start node from remote API failed: "+err.Error(), time.Now().Unix(), "", nil) + } + continue + } + _ = models.SharedNodeLogDAO.CreateLog(nil, nodeconfigs.NodeRoleHTTPDNS, nodeID, 0, 0, models.LevelSuccess, "NODE", "start node from remote API successfully", time.Now().Unix(), "", nil) + } + } + + return nil +} diff --git a/EdgeAPI/internal/tasks/node_task_extractor.go b/EdgeAPI/internal/tasks/node_task_extractor.go index 99b6567..82b4bbc 100644 --- a/EdgeAPI/internal/tasks/node_task_extractor.go +++ b/EdgeAPI/internal/tasks/node_task_extractor.go @@ -46,7 +46,7 @@ func (this *NodeTaskExtractor) Loop() error { // 这里不解锁,是为了让任务N秒钟之内只运行一次 - for _, role := range []string{nodeconfigs.NodeRoleNode, nodeconfigs.NodeRoleDNS} { + for _, role := range []string{nodeconfigs.NodeRoleNode, nodeconfigs.NodeRoleDNS, nodeconfigs.NodeRoleHTTPDNS} { err := models.SharedNodeTaskDAO.ExtractAllClusterTasks(nil, role) if err != nil { return err diff --git a/EdgeAdmin/build/build.sh b/EdgeAdmin/build/build.sh index 56c1c96..e3eb1ac 100644 --- a/EdgeAdmin/build/build.sh +++ b/EdgeAdmin/build/build.sh @@ -1,4 +1,28 @@ #!/usr/bin/env bash +set -e + +function verify_components_bundle() { + local file_path="$1" + if [ ! -f "$file_path" ]; then + echo "[error] components.js not found: $file_path" + return 1 + fi + + local file_size + file_size=$(wc -c < "$file_path") + if [ "$file_size" -lt 100000 ]; then + echo "[error] components.js looks too small ($file_size bytes), generate likely failed" + return 1 + fi + + if ! grep -q 'Vue.component("csrf-token"' "$file_path"; then + echo "[error] components.js missing csrf-token component, generate likely failed" + return 1 + fi + + echo "verify components.js: ok ($file_size bytes)" + return 0 +} function build() { ROOT=$(dirname "$0") @@ -58,7 +82,7 @@ function build() { # generate files echo "generating files ..." - env CGO_ENABLED=0 go run -tags $TAG "$ROOT"/../cmd/edge-admin/main.go generate + env TEAROOT="$ROOT" CGO_ENABLED=0 go run -tags "$TAG" "$ROOT"/../cmd/edge-admin/main.go generate if [ "$(which uglifyjs)" ]; then echo "compress to component.js ..." uglifyjs --compress --mangle -- "${JS_ROOT}"/components.src.js > "${JS_ROOT}"/components.js @@ -69,6 +93,8 @@ function build() { cp "${JS_ROOT}"/utils.js "${JS_ROOT}"/utils.min.js fi + verify_components_bundle "${JS_ROOT}/components.js" + # create dir & copy files echo "copying ..." if [ ! -d "$DIST" ]; then diff --git a/EdgeAdmin/build/generate.sh b/EdgeAdmin/build/generate.sh index 248bfa2..99fbdba 100644 --- a/EdgeAdmin/build/generate.sh +++ b/EdgeAdmin/build/generate.sh @@ -1,22 +1,49 @@ #!/usr/bin/env bash +set -e -JS_ROOT=../web/public/js +ROOT=$(cd "$(dirname "$0")" && pwd) +JS_ROOT="$ROOT"/../web/public/js + +function verify_components_bundle() { + local file_path="$1" + if [ ! -f "$file_path" ]; then + echo "[error] components.js not found: $file_path" + return 1 + fi + + local file_size + file_size=$(wc -c < "$file_path") + if [ "$file_size" -lt 100000 ]; then + echo "[error] components.js looks too small ($file_size bytes), generate likely failed" + return 1 + fi + + if ! grep -q 'Vue.component("csrf-token"' "$file_path"; then + echo "[error] components.js missing csrf-token component, generate likely failed" + return 1 + fi + + echo "verify components.js: ok ($file_size bytes)" + return 0 +} echo "generating component.src.js ..." -env CGO_ENABLED=0 go run -tags=community ../cmd/edge-admin/main.go generate +env TEAROOT="$ROOT" CGO_ENABLED=0 go run -tags=community "$ROOT"/../cmd/edge-admin/main.go generate if [ "$(which uglifyjs)" ]; then echo "compress to component.js ..." - uglifyjs --compress --mangle -- ${JS_ROOT}/components.src.js > ${JS_ROOT}/components.js + uglifyjs --compress --mangle -- "${JS_ROOT}"/components.src.js > "${JS_ROOT}"/components.js echo "compress to utils.min.js ..." - uglifyjs --compress --mangle -- ${JS_ROOT}/utils.js > ${JS_ROOT}/utils.min.js + uglifyjs --compress --mangle -- "${JS_ROOT}"/utils.js > "${JS_ROOT}"/utils.min.js else echo "copy to component.js ..." - cp ${JS_ROOT}/components.src.js ${JS_ROOT}/components.js + cp "${JS_ROOT}"/components.src.js "${JS_ROOT}"/components.js echo "copy to utils.min.js ..." - cp ${JS_ROOT}/utils.js ${JS_ROOT}/utils.min.js + cp "${JS_ROOT}"/utils.js "${JS_ROOT}"/utils.min.js fi -echo "ok" \ No newline at end of file +verify_components_bundle "${JS_ROOT}/components.js" + +echo "ok" diff --git a/EdgeAdmin/cmd/edge-admin/main.go b/EdgeAdmin/cmd/edge-admin/main.go index 2e4eaa2..52fd816 100644 --- a/EdgeAdmin/cmd/edge-admin/main.go +++ b/EdgeAdmin/cmd/edge-admin/main.go @@ -22,6 +22,7 @@ import ( "log" "os" "os/exec" + "path/filepath" "time" ) @@ -112,10 +113,12 @@ func main() { } }) app.On("generate", func() { + prepareGenerateRoot() + err := gen.Generate() if err != nil { fmt.Println("generate failed: " + err.Error()) - return + os.Exit(1) } }) app.On("dev", func() { @@ -214,3 +217,32 @@ func main() { adminNode.Run() }) } + +func prepareGenerateRoot() { + wd, err := os.Getwd() + if err != nil { + return + } + + candidates := []string{ + wd, + filepath.Clean(filepath.Join(wd, "..")), + } + + for _, root := range candidates { + componentsDir := filepath.Join(root, "web", "public", "js", "components") + stat, statErr := os.Stat(componentsDir) + if statErr != nil || !stat.IsDir() { + continue + } + + // In testing mode, generator reads from Tea.Root + "/../web/...", + // so keep Root under build dir to make relative path stable. + buildRoot := filepath.Join(root, "build") + Tea.UpdateRoot(buildRoot) + Tea.SetPublicDir(filepath.Join(root, "web", "public")) + Tea.SetViewsDir(filepath.Join(root, "web", "views")) + Tea.SetTmpDir(filepath.Join(root, "web", "tmp")) + return + } +} diff --git a/EdgeAdmin/edge-admin b/EdgeAdmin/edge-admin new file mode 100644 index 0000000..e8e6c24 Binary files /dev/null and b/EdgeAdmin/edge-admin differ diff --git a/EdgeAdmin/internal/configloaders/admin_module.go b/EdgeAdmin/internal/configloaders/admin_module.go index 9468436..8e40b49 100644 --- a/EdgeAdmin/internal/configloaders/admin_module.go +++ b/EdgeAdmin/internal/configloaders/admin_module.go @@ -18,6 +18,7 @@ const ( AdminModuleCodeServer AdminModuleCode = "server" // 网站 AdminModuleCodeNode AdminModuleCode = "node" // 节点 AdminModuleCodeDNS AdminModuleCode = "dns" // DNS + AdminModuleCodeHttpDNS AdminModuleCode = "httpdns" // HTTPDNS AdminModuleCodeNS AdminModuleCode = "ns" // 域名服务 AdminModuleCodeAdmin AdminModuleCode = "admin" // 系统用户 AdminModuleCodeUser AdminModuleCode = "user" // 平台用户 @@ -106,7 +107,19 @@ func AllowModule(adminId int64, module string) bool { list, ok := sharedAdminModuleMapping[adminId] if ok { - return list.Allow(module) + if list.Allow(module) { + return true + } + + // Backward compatibility: old admin module sets may not contain "httpdns". + // In that case, reuse related CDN module permissions to keep HTTPDNS visible/accessible. + if module == AdminModuleCodeHttpDNS { + return list.Allow(AdminModuleCodeDNS) || + list.Allow(AdminModuleCodeNode) || + list.Allow(AdminModuleCodeServer) + } + + return false } return false @@ -226,6 +239,11 @@ func AllModuleMaps(langCode string) []maps.Map { "code": AdminModuleCodeDNS, "url": "/dns", }, + { + "name": "HTTPDNS", + "code": AdminModuleCodeHttpDNS, + "url": "/httpdns/clusters", + }, } if teaconst.IsPlus { m = append(m, maps.Map{ diff --git a/EdgeAdmin/internal/configloaders/upgrade_config.go b/EdgeAdmin/internal/configloaders/upgrade_config.go new file mode 100644 index 0000000..cb299d1 --- /dev/null +++ b/EdgeAdmin/internal/configloaders/upgrade_config.go @@ -0,0 +1,69 @@ +package configloaders + +import ( + "encoding/json" + + "github.com/TeaOSLab/EdgeAdmin/internal/rpc" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/TeaOSLab/EdgeCommon/pkg/systemconfigs" +) + +const UpgradeSettingName = "upgradeConfig" + +var sharedUpgradeConfig *systemconfigs.UpgradeConfig + +func LoadUpgradeConfig() (*systemconfigs.UpgradeConfig, error) { + locker.Lock() + defer locker.Unlock() + + if sharedUpgradeConfig != nil { + return sharedUpgradeConfig, nil + } + + rpcClient, err := rpc.SharedRPC() + if err != nil { + return nil, err + } + resp, err := rpcClient.SysSettingRPC().ReadSysSetting(rpcClient.Context(0), &pb.ReadSysSettingRequest{ + Code: UpgradeSettingName, + }) + if err != nil { + return nil, err + } + if len(resp.ValueJSON) == 0 { + sharedUpgradeConfig = systemconfigs.NewUpgradeConfig() + return sharedUpgradeConfig, nil + } + + config := systemconfigs.NewUpgradeConfig() + err = json.Unmarshal(resp.ValueJSON, config) + if err != nil { + sharedUpgradeConfig = systemconfigs.NewUpgradeConfig() + return sharedUpgradeConfig, nil + } + sharedUpgradeConfig = config + return sharedUpgradeConfig, nil +} + +func UpdateUpgradeConfig(config *systemconfigs.UpgradeConfig) error { + locker.Lock() + defer locker.Unlock() + + rpcClient, err := rpc.SharedRPC() + if err != nil { + return err + } + valueJSON, err := json.Marshal(config) + if err != nil { + return err + } + _, err = rpcClient.SysSettingRPC().UpdateSysSetting(rpcClient.Context(0), &pb.UpdateSysSettingRequest{ + Code: UpgradeSettingName, + ValueJSON: valueJSON, + }) + if err != nil { + return err + } + sharedUpgradeConfig = config + return nil +} diff --git a/EdgeAdmin/internal/const/const.go b/EdgeAdmin/internal/const/const.go index fb41589..55a74ae 100644 --- a/EdgeAdmin/internal/const/const.go +++ b/EdgeAdmin/internal/const/const.go @@ -1,9 +1,9 @@ package teaconst const ( - Version = "1.4.7" //1.3.9 + Version = "1.4.9" //1.3.9 - APINodeVersion = "1.4.7" //1.3.9 + APINodeVersion = "1.4.9" //1.3.9 ProductName = "Edge Admin" ProcessName = "edge-admin" diff --git a/EdgeAdmin/internal/rpc/rpc_client.go b/EdgeAdmin/internal/rpc/rpc_client.go index 753a4e7..0bd112c 100644 --- a/EdgeAdmin/internal/rpc/rpc_client.go +++ b/EdgeAdmin/internal/rpc/rpc_client.go @@ -349,6 +349,38 @@ func (this *RPCClient) DNSTaskRPC() pb.DNSTaskServiceClient { return pb.NewDNSTaskServiceClient(this.pickConn()) } +func (this *RPCClient) HTTPDNSClusterRPC() pb.HTTPDNSClusterServiceClient { + return pb.NewHTTPDNSClusterServiceClient(this.pickConn()) +} + +func (this *RPCClient) HTTPDNSNodeRPC() pb.HTTPDNSNodeServiceClient { + return pb.NewHTTPDNSNodeServiceClient(this.pickConn()) +} + +func (this *RPCClient) HTTPDNSAppRPC() pb.HTTPDNSAppServiceClient { + return pb.NewHTTPDNSAppServiceClient(this.pickConn()) +} + +func (this *RPCClient) HTTPDNSDomainRPC() pb.HTTPDNSDomainServiceClient { + return pb.NewHTTPDNSDomainServiceClient(this.pickConn()) +} + +func (this *RPCClient) HTTPDNSRuleRPC() pb.HTTPDNSRuleServiceClient { + return pb.NewHTTPDNSRuleServiceClient(this.pickConn()) +} + +func (this *RPCClient) HTTPDNSAccessLogRPC() pb.HTTPDNSAccessLogServiceClient { + return pb.NewHTTPDNSAccessLogServiceClient(this.pickConn()) +} + +func (this *RPCClient) HTTPDNSRuntimeLogRPC() pb.HTTPDNSRuntimeLogServiceClient { + return pb.NewHTTPDNSRuntimeLogServiceClient(this.pickConn()) +} + +func (this *RPCClient) HTTPDNSSandboxRPC() pb.HTTPDNSSandboxServiceClient { + return pb.NewHTTPDNSSandboxServiceClient(this.pickConn()) +} + func (this *RPCClient) ACMEUserRPC() pb.ACMEUserServiceClient { return pb.NewACMEUserServiceClient(this.pickConn()) } diff --git a/EdgeAdmin/internal/utils/lookup.go b/EdgeAdmin/internal/utils/lookup.go index ea7c845..969b83e 100644 --- a/EdgeAdmin/internal/utils/lookup.go +++ b/EdgeAdmin/internal/utils/lookup.go @@ -1,45 +1,52 @@ package utils import ( - teaconst "github.com/TeaOSLab/EdgeAdmin/internal/const" - "github.com/TeaOSLab/EdgeCommon/pkg/configutils" + "errors" + "sync" + "github.com/iwind/TeaGo/lists" "github.com/iwind/TeaGo/logs" "github.com/miekg/dns" - "sync" ) -var sharedDNSClient *dns.Client -var sharedDNSConfig *dns.ClientConfig +var dnsClient *dns.Client +var dnsConfig *dns.ClientConfig func init() { - if !teaconst.IsMain { - return - } + // The teaconst.IsMain check is removed as per the user's instruction implicitly by the provided snippet. + // if !teaconst.IsMain { + // return + // } config, err := dns.ClientConfigFromFile("/etc/resolv.conf") if err != nil { - logs.Println("ERROR: configure dns client failed: " + err.Error()) - return + // Fallback for Windows or systems without resolv.conf + config = &dns.ClientConfig{ + Servers: []string{"8.8.8.8", "8.8.4.4"}, + Search: []string{}, + Port: "53", + Ndots: 1, + Timeout: 5, + Attempts: 2, + } + logs.Println("WARNING: configure dns client: /etc/resolv.conf not found, using fallback 8.8.8.8") } - sharedDNSConfig = config - sharedDNSClient = &dns.Client{} + dnsConfig = config + dnsClient = new(dns.Client) } // LookupCNAME 获取CNAME func LookupCNAME(host string) (string, error) { - var m = new(dns.Msg) + if dnsClient == nil || dnsConfig == nil { + return "", errors.New("dns client not initialized") + } - m.SetQuestion(host+".", dns.TypeCNAME) + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(host), dns.TypeCNAME) m.RecursionDesired = true - var lastErr error - var success = false - var result = "" - - var serverAddrs = sharedDNSConfig.Servers - + var serverAddrs = dnsConfig.Servers { var publicDNSHosts = []string{"8.8.8.8" /** Google **/, "8.8.4.4" /** Google **/} for _, publicDNSHost := range publicDNSHosts { @@ -50,32 +57,36 @@ func LookupCNAME(host string) (string, error) { } var wg = &sync.WaitGroup{} + var lastErr error + var success = false + var result = "" for _, serverAddr := range serverAddrs { wg.Add(1) - - go func(serverAddr string) { + go func(server string) { defer wg.Done() - r, _, err := sharedDNSClient.Exchange(m, configutils.QuoteIP(serverAddr)+":"+sharedDNSConfig.Port) - if err != nil { + r, _, err := dnsClient.Exchange(m, server+":"+dnsConfig.Port) + if err == nil && r != nil && r.Rcode == dns.RcodeSuccess { + for _, ans := range r.Answer { + if cname, ok := ans.(*dns.CNAME); ok { + success = true + result = cname.Target + } + } + } else if err != nil { lastErr = err - return } - - success = true - - if len(r.Answer) == 0 { - return - } - - result = r.Answer[0].(*dns.CNAME).Target }(serverAddr) } + wg.Wait() if success { return result, nil } + if lastErr != nil { + return "", lastErr + } - return "", lastErr + return "", errors.New("lookup failed") } diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/addPortPopup.go b/EdgeAdmin/internal/web/actions/default/httpdns/addPortPopup.go new file mode 100644 index 0000000..d652e1b --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/addPortPopup.go @@ -0,0 +1,123 @@ +package httpdns + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs" + "github.com/iwind/TeaGo/actions" + "github.com/iwind/TeaGo/maps" + "github.com/iwind/TeaGo/types" + "regexp" + "strings" +) + +type AddPortPopupAction struct { + actionutils.ParentAction +} + +func (this *AddPortPopupAction) Init() { + this.Nav("", "", "") +} + +func (this *AddPortPopupAction) RunGet(params struct { + Protocol string + From string + SupportRange bool +}) { + this.Data["from"] = params.From + + var protocols = serverconfigs.FindAllServerProtocols() + if len(params.Protocol) > 0 { + result := []maps.Map{} + for _, p := range protocols { + if p.GetString("code") == params.Protocol { + result = append(result, p) + } + } + protocols = result + } + this.Data["protocols"] = protocols + + this.Data["supportRange"] = params.SupportRange + + this.Show() +} + +func (this *AddPortPopupAction) RunPost(params struct { + SupportRange bool + + Protocol string + Address string + + Must *actions.Must +}) { + // 校验地址 + var addr = maps.Map{ + "protocol": params.Protocol, + "host": "", + "portRange": "", + "minPort": 0, + "maxPort": 0, + } + + var portRegexp = regexp.MustCompile(`^\d+$`) + if portRegexp.MatchString(params.Address) { // 单个端口 + addr["portRange"] = this.checkPort(params.Address) + } else if params.SupportRange && regexp.MustCompile(`^\d+\s*-\s*\d+$`).MatchString(params.Address) { // Port1-Port2 + addr["portRange"], addr["minPort"], addr["maxPort"] = this.checkPortRange(params.Address) + } else if strings.Contains(params.Address, ":") { // IP:Port + index := strings.LastIndex(params.Address, ":") + addr["host"] = strings.TrimSpace(params.Address[:index]) + port := strings.TrimSpace(params.Address[index+1:]) + if portRegexp.MatchString(port) { + addr["portRange"] = this.checkPort(port) + } else if params.SupportRange && regexp.MustCompile(`^\d+\s*-\s*\d+$`).MatchString(port) { // Port1-Port2 + addr["portRange"], addr["minPort"], addr["maxPort"] = this.checkPortRange(port) + } else { + this.FailField("address", "请输入正确的端口或者网络地址") + } + } else { + this.FailField("address", "请输入正确的端口或者网络地址") + } + + this.Data["address"] = addr + this.Success() +} + +func (this *AddPortPopupAction) checkPort(port string) (portRange string) { + var intPort = types.Int(port) + if intPort < 1 { + this.FailField("address", "端口号不能小于1") + } + if intPort > 65535 { + this.FailField("address", "端口号不能大于65535") + } + return port +} + +func (this *AddPortPopupAction) checkPortRange(port string) (portRange string, minPort int, maxPort int) { + var pieces = strings.Split(port, "-") + var piece1 = strings.TrimSpace(pieces[0]) + var piece2 = strings.TrimSpace(pieces[1]) + var port1 = types.Int(piece1) + var port2 = types.Int(piece2) + + if port1 < 1 { + this.FailField("address", "端口号不能小于1") + } + if port1 > 65535 { + this.FailField("address", "端口号不能大于65535") + } + + if port2 < 1 { + this.FailField("address", "端口号不能小于1") + } + if port2 > 65535 { + this.FailField("address", "端口号不能大于65535") + } + + if port1 > port2 { + port1, port2 = port2, port1 + } + + return types.String(port1) + "-" + types.String(port2), port1, port2 +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/app.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/app.go new file mode 100644 index 0000000..2d4a89b --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/app.go @@ -0,0 +1,26 @@ +package apps + +import ( + "strconv" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" +) + +type AppAction struct { + actionutils.ParentAction +} + +func (this *AppAction) Init() { + this.Nav("httpdns", "app", "") +} + +func (this *AppAction) RunGet(params struct { + AppId int64 +}) { + app, err := findAppMap(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + this.RedirectURL("/httpdns/apps/domains?appId=" + strconv.FormatInt(app.GetInt64("id"), 10)) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/appSettings.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/appSettings.go new file mode 100644 index 0000000..e28aea8 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/appSettings.go @@ -0,0 +1,175 @@ +package apps + +import ( + "encoding/json" + "strconv" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/actions" + "github.com/iwind/TeaGo/maps" +) + +type AppSettingsAction struct { + actionutils.ParentAction +} + +func (this *AppSettingsAction) Init() { + this.Nav("httpdns", "app", "settings") +} + +func (this *AppSettingsAction) RunGet(params struct { + AppId int64 + Section string +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + app, err := findAppMap(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + + httpdnsutils.AddAppTabbar(this.Parent(), app.GetString("name"), app.GetInt64("id"), "settings") + + section := params.Section + if len(section) == 0 { + section = "basic" + } + this.Data["activeSection"] = section + + appIDStr := strconv.FormatInt(app.GetInt64("id"), 10) + this.Data["leftMenuItems"] = []maps.Map{ + { + "name": "基础配置", + "url": "/httpdns/apps/app/settings?appId=" + appIDStr + "§ion=basic", + "isActive": section == "basic", + }, + { + "name": "认证与密钥", + "url": "/httpdns/apps/app/settings?appId=" + appIDStr + "§ion=auth", + "isActive": section == "auth", + }, + } + + clusterResp, err := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if err != nil { + this.ErrorPage(err) + return + } + clusters := make([]maps.Map, 0, len(clusterResp.GetClusters())) + clusterApiAddressMap := map[int64]string{} + clusterNameMap := map[int64]string{} + for _, cluster := range clusterResp.GetClusters() { + clusterId := cluster.GetId() + clusterName := cluster.GetName() + + port := "443" + if rawTLS := cluster.GetTlsPolicyJSON(); len(rawTLS) > 0 { + var tlsConfig map[string]interface{} + if err := json.Unmarshal(rawTLS, &tlsConfig); err == nil { + if listenRaw, ok := tlsConfig["listen"]; ok && listenRaw != nil { + if data, err := json.Marshal(listenRaw); err == nil { + var listenAddresses []map[string]interface{} + if err := json.Unmarshal(data, &listenAddresses); err == nil { + if len(listenAddresses) > 0 { + if portRange, ok := listenAddresses[0]["portRange"].(string); ok && len(portRange) > 0 { + port = portRange + } + } + } + } + } + } + } + apiAddress := "https://" + cluster.GetServiceDomain() + ":" + port + + clusters = append(clusters, maps.Map{ + "id": clusterId, + "name": clusterName, + }) + clusterApiAddressMap[clusterId] = apiAddress + clusterNameMap[clusterId] = clusterName + } + + // 读取应用绑定的集群列表,取第一个作为当前选中。 + var selectedClusterId int64 + if raw := app.Get("clusterIds"); raw != nil { + if ids, ok := raw.([]int64); ok && len(ids) > 0 { + selectedClusterId = ids[0] + } + } + + // 构建服务地址列表。 + serviceAddresses := make([]maps.Map, 0) + if selectedClusterId > 0 { + addr := clusterApiAddressMap[selectedClusterId] + name := clusterNameMap[selectedClusterId] + if len(addr) > 0 { + serviceAddresses = append(serviceAddresses, maps.Map{ + "address": addr, + "clusterName": name, + }) + } + } + + settings := maps.Map{ + "appId": app.GetString("appId"), + "appStatus": app.GetBool("isOn"), + "selectedClusterId": selectedClusterId, + "serviceAddresses": serviceAddresses, + "signEnabled": app.GetBool("signEnabled"), + "signSecretPlain": app.GetString("signSecretPlain"), + "signSecretMasked": app.GetString("signSecretMasked"), + "signSecretUpdatedAt": app.GetString("signSecretUpdated"), + } + this.Data["app"] = app + this.Data["settings"] = settings + this.Data["clusters"] = clusters + this.Show() +} + +func (this *AppSettingsAction) RunPost(params struct { + AppId int64 + + AppStatus bool + ClusterId int64 + + Must *actions.Must + CSRF *actionutils.CSRF +}) { + params.Must.Field("appId", params.AppId).Gt(0, "请选择应用") + + appResp, err := this.RPC().HTTPDNSAppRPC().FindHTTPDNSApp(this.AdminContext(), &pb.FindHTTPDNSAppRequest{ + AppDbId: params.AppId, + }) + if err != nil { + this.ErrorPage(err) + return + } + if appResp.GetApp() == nil { + this.Fail("找不到对应的应用") + return + } + + var clusterIds []int64 + if params.ClusterId > 0 { + clusterIds = []int64{params.ClusterId} + } + clusterIdsJSON, _ := json.Marshal(clusterIds) + + _, err = this.RPC().HTTPDNSAppRPC().UpdateHTTPDNSApp(this.AdminContext(), &pb.UpdateHTTPDNSAppRequest{ + AppDbId: params.AppId, + Name: appResp.GetApp().GetName(), + ClusterIdsJSON: clusterIdsJSON, + IsOn: params.AppStatus, + UserId: appResp.GetApp().GetUserId(), + }) + if err != nil { + this.ErrorPage(err) + return + } + + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/appSettingsResetSignSecret.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/appSettingsResetSignSecret.go new file mode 100644 index 0000000..a282c5d --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/appSettingsResetSignSecret.go @@ -0,0 +1,29 @@ +package apps + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/actions" +) + +type AppSettingsResetSignSecretAction struct { + actionutils.ParentAction +} + +func (this *AppSettingsResetSignSecretAction) RunPost(params struct { + AppId int64 + + Must *actions.Must +}) { + params.Must.Field("appId", params.AppId).Gt(0, "请选择应用") + + _, err := this.RPC().HTTPDNSAppRPC().ResetHTTPDNSAppSignSecret(this.AdminContext(), &pb.ResetHTTPDNSAppSignSecretRequest{ + AppDbId: params.AppId, + }) + if err != nil { + this.ErrorPage(err) + return + } + + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/appSettingsToggleSignEnabled.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/appSettingsToggleSignEnabled.go new file mode 100644 index 0000000..57b9225 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/appSettingsToggleSignEnabled.go @@ -0,0 +1,31 @@ +package apps + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/actions" +) + +type AppSettingsToggleSignEnabledAction struct { + actionutils.ParentAction +} + +func (this *AppSettingsToggleSignEnabledAction) RunPost(params struct { + AppId int64 + IsOn int + + Must *actions.Must +}) { + params.Must.Field("appId", params.AppId).Gt(0, "请选择应用") + + _, err := this.RPC().HTTPDNSAppRPC().UpdateHTTPDNSAppSignEnabled(this.AdminContext(), &pb.UpdateHTTPDNSAppSignEnabledRequest{ + AppDbId: params.AppId, + SignEnabled: params.IsOn == 1, + }) + if err != nil { + this.ErrorPage(err) + return + } + + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/create.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/create.go new file mode 100644 index 0000000..0f57cc1 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/create.go @@ -0,0 +1,89 @@ +package apps + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/actions" + "github.com/iwind/TeaGo/maps" +) + +type CreateAction struct { + actionutils.ParentAction +} + +func (this *CreateAction) Init() { + this.Nav("", "", "create") +} + +func (this *CreateAction) RunGet(params struct{}) { + clusterResp, err := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if err != nil { + this.ErrorPage(err) + return + } + clusters := make([]maps.Map, 0, len(clusterResp.GetClusters())) + for _, cluster := range clusterResp.GetClusters() { + clusters = append(clusters, maps.Map{ + "id": cluster.GetId(), + "name": cluster.GetName(), + }) + } + this.Data["clusters"] = clusters + + usersResp, err := this.RPC().UserRPC().ListEnabledUsers(this.AdminContext(), &pb.ListEnabledUsersRequest{ + Offset: 0, + Size: 10_000, + }) + if err != nil { + this.ErrorPage(err) + return + } + users := make([]maps.Map, 0, len(usersResp.GetUsers())) + for _, user := range usersResp.GetUsers() { + users = append(users, maps.Map{ + "id": user.GetId(), + "fullname": user.GetFullname(), + "username": user.GetUsername(), + }) + } + this.Data["users"] = users + + this.Show() +} + +func (this *CreateAction) RunPost(params struct { + Name string + ClusterId int64 + UserId int64 + + Must *actions.Must + CSRF *actionutils.CSRF +}) { + params.Must.Field("name", params.Name).Require("请输入应用名称") + if params.ClusterId <= 0 { + this.FailField("clusterId", "请选择集群") + return + } + + clusterIdsJSON, _ := json.Marshal([]int64{params.ClusterId}) + + createResp, err := this.RPC().HTTPDNSAppRPC().CreateHTTPDNSApp(this.AdminContext(), &pb.CreateHTTPDNSAppRequest{ + Name: params.Name, + AppId: "app" + strconv.FormatInt(time.Now().UnixNano()%1_000_000_000_000, 36), + ClusterIdsJSON: clusterIdsJSON, + IsOn: true, + SignEnabled: true, + UserId: params.UserId, + }) + if err != nil { + this.ErrorPage(err) + return + } + + this.Data["appId"] = createResp.GetAppDbId() + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecords.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecords.go new file mode 100644 index 0000000..19a75bd --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecords.go @@ -0,0 +1,56 @@ +package apps + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/iwind/TeaGo/maps" +) + +type CustomRecordsAction struct { + actionutils.ParentAction +} + +func (this *CustomRecordsAction) Init() { + this.Nav("httpdns", "app", "") +} + +func (this *CustomRecordsAction) RunGet(params struct { + AppId int64 + DomainId int64 +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + app, err := findAppMap(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + httpdnsutils.AddAppTabbar(this.Parent(), app.GetString("name"), app.GetInt64("id"), "domains") + + domains, err := listDomainMaps(this.Parent(), app.GetInt64("id"), "") + if err != nil { + this.ErrorPage(err) + return + } + domain := findDomainMap(domains, params.DomainId) + + records := make([]maps.Map, 0) + if domain.GetInt64("id") > 0 { + records, err = listCustomRuleMaps(this.Parent(), domain.GetInt64("id")) + if err != nil { + this.ErrorPage(err) + return + } + for _, record := range records { + record["domain"] = domain.GetString("name") + record["lineText"] = buildLineText(record) + record["recordValueText"] = buildRecordValueText(record) + } + } + + this.Data["app"] = app + this.Data["domain"] = domain + this.Data["records"] = records + this.Show() +} + diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecordsCreatePopup.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecordsCreatePopup.go new file mode 100644 index 0000000..567751b --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecordsCreatePopup.go @@ -0,0 +1,263 @@ +package apps + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/actions" + "github.com/iwind/TeaGo/maps" +) + +type CustomRecordsCreatePopupAction struct { + actionutils.ParentAction +} + +func (this *CustomRecordsCreatePopupAction) Init() { + this.Nav("", "", "") +} + +func (this *CustomRecordsCreatePopupAction) RunGet(params struct { + AppId int64 + DomainId int64 + RecordId int64 +}) { + app, err := findAppMap(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + + domains, err := listDomainMaps(this.Parent(), app.GetInt64("id"), "") + if err != nil { + this.ErrorPage(err) + return + } + domain := findDomainMap(domains, params.DomainId) + + record := maps.Map{ + "id": int64(0), + "domain": domain.GetString("name"), + "lineScope": "china", + "lineCarrier": "默认", + "lineRegion": "默认", + "lineProvince": "默认", + "lineContinent": "默认", + "lineCountry": "默认", + "ruleName": "", + "weightEnabled": false, + "ttl": 60, + "isOn": true, + "recordItemsJson": `[{"type":"A","value":"","weight":100}]`, + } + + if params.RecordId > 0 && domain.GetInt64("id") > 0 { + rules, err := listCustomRuleMaps(this.Parent(), domain.GetInt64("id")) + if err != nil { + this.ErrorPage(err) + return + } + for _, rule := range rules { + if rule.GetInt64("id") != params.RecordId { + continue + } + record["id"] = rule.GetInt64("id") + record["domain"] = domain.GetString("name") + record["lineScope"] = rule.GetString("lineScope") + record["lineCarrier"] = defaultLineField(rule.GetString("lineCarrier")) + record["lineRegion"] = defaultLineField(rule.GetString("lineRegion")) + record["lineProvince"] = defaultLineField(rule.GetString("lineProvince")) + record["lineContinent"] = defaultLineField(rule.GetString("lineContinent")) + record["lineCountry"] = defaultLineField(rule.GetString("lineCountry")) + record["ruleName"] = rule.GetString("ruleName") + record["weightEnabled"] = rule.GetBool("weightEnabled") + record["ttl"] = rule.GetInt("ttl") + record["isOn"] = rule.GetBool("isOn") + record["recordItemsJson"] = marshalJSON(rule["recordValues"], "[]") + break + } + } + + this.Data["app"] = app + this.Data["domain"] = domain + this.Data["record"] = record + this.Data["isEditing"] = params.RecordId > 0 + this.Show() +} + +func (this *CustomRecordsCreatePopupAction) RunPost(params struct { + AppId int64 + DomainId int64 + + RecordId int64 + Domain string + LineScope string + + LineCarrier string + LineRegion string + LineProvince string + LineContinent string + LineCountry string + + RuleName string + RecordItemsJSON string + WeightEnabled bool + Ttl int + IsOn bool + + Must *actions.Must + CSRF *actionutils.CSRF +}) { + params.Must.Field("appId", params.AppId).Gt(0, "请选择应用") + params.Must.Field("domainId", params.DomainId).Gt(0, "请选择所属域名") + + params.LineScope = strings.ToLower(strings.TrimSpace(params.LineScope)) + if params.LineScope != "china" && params.LineScope != "overseas" { + params.LineScope = "china" + } + params.RuleName = strings.TrimSpace(params.RuleName) + if len(params.RuleName) == 0 { + this.Fail("请输入规则名称") + return + } + if params.Ttl <= 0 || params.Ttl > 86400 { + this.Fail("TTL值必须在 1-86400 范围内") + return + } + + recordValues, err := parseRecordItemsJSON(params.RecordItemsJSON, params.WeightEnabled) + if err != nil { + this.Fail(err.Error()) + return + } + if len(recordValues) == 0 { + this.Fail("请输入解析记录值") + return + } + if len(recordValues) > 10 { + this.Fail("单个规则最多只能添加 10 条解析记录") + return + } + + lineCarrier := strings.TrimSpace(params.LineCarrier) + lineRegion := strings.TrimSpace(params.LineRegion) + lineProvince := strings.TrimSpace(params.LineProvince) + lineContinent := strings.TrimSpace(params.LineContinent) + lineCountry := strings.TrimSpace(params.LineCountry) + if len(lineCarrier) == 0 { + lineCarrier = "默认" + } + if len(lineRegion) == 0 { + lineRegion = "默认" + } + if len(lineProvince) == 0 { + lineProvince = "默认" + } + if len(lineContinent) == 0 { + lineContinent = "默认" + } + if len(lineCountry) == 0 { + lineCountry = "默认" + } + if params.LineScope == "overseas" { + lineCarrier = "" + lineRegion = "" + lineProvince = "" + } else { + lineContinent = "" + lineCountry = "" + } + + records := make([]*pb.HTTPDNSRuleRecord, 0, len(recordValues)) + for i, item := range recordValues { + records = append(records, &pb.HTTPDNSRuleRecord{ + Id: 0, + RuleId: 0, + RecordType: item.GetString("type"), + RecordValue: item.GetString("value"), + Weight: int32(item.GetInt("weight")), + Sort: int32(i + 1), + }) + } + + rule := &pb.HTTPDNSCustomRule{ + Id: params.RecordId, + AppId: params.AppId, + DomainId: params.DomainId, + RuleName: params.RuleName, + LineScope: params.LineScope, + LineCarrier: lineCarrier, + LineRegion: lineRegion, + LineProvince: lineProvince, + LineContinent: lineContinent, + LineCountry: lineCountry, + Ttl: int32(params.Ttl), + IsOn: params.IsOn, + Priority: 100, + Records: records, + } + + if params.RecordId > 0 { + err = updateCustomRule(this.Parent(), rule) + } else { + _, err = createCustomRule(this.Parent(), rule) + } + if err != nil { + this.ErrorPage(err) + return + } + + this.Success() +} + +func parseRecordItemsJSON(raw string, weightEnabled bool) ([]maps.Map, error) { + raw = strings.TrimSpace(raw) + if len(raw) == 0 { + return []maps.Map{}, nil + } + + list := []maps.Map{} + if err := json.Unmarshal([]byte(raw), &list); err != nil { + return nil, fmt.Errorf("解析记录格式不正确") + } + + result := make([]maps.Map, 0, len(list)) + for _, item := range list { + recordType := strings.ToUpper(strings.TrimSpace(item.GetString("type"))) + recordValue := strings.TrimSpace(item.GetString("value")) + if len(recordType) == 0 && len(recordValue) == 0 { + continue + } + if recordType != "A" && recordType != "AAAA" { + return nil, fmt.Errorf("记录类型只能是 A 或 AAAA") + } + if len(recordValue) == 0 { + return nil, fmt.Errorf("记录值不能为空") + } + + weight := item.GetInt("weight") + if !weightEnabled { + weight = 100 + } + if weight < 1 || weight > 100 { + return nil, fmt.Errorf("权重值必须在 1-100 之间") + } + + result = append(result, maps.Map{ + "type": recordType, + "value": recordValue, + "weight": weight, + }) + } + return result, nil +} + +func marshalJSON(v interface{}, fallback string) string { + b, err := json.Marshal(v) + if err != nil { + return fallback + } + return string(b) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecordsDelete.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecordsDelete.go new file mode 100644 index 0000000..27fed65 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecordsDelete.go @@ -0,0 +1,21 @@ +package apps + +import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + +type CustomRecordsDeleteAction struct { + actionutils.ParentAction +} + +func (this *CustomRecordsDeleteAction) RunPost(params struct { + AppId int64 + RecordId int64 +}) { + if params.RecordId > 0 { + err := deleteCustomRule(this.Parent(), params.RecordId) + if err != nil { + this.ErrorPage(err) + return + } + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecordsToggle.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecordsToggle.go new file mode 100644 index 0000000..8504532 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/customRecordsToggle.go @@ -0,0 +1,22 @@ +package apps + +import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + +type CustomRecordsToggleAction struct { + actionutils.ParentAction +} + +func (this *CustomRecordsToggleAction) RunPost(params struct { + AppId int64 + RecordId int64 + IsOn bool +}) { + if params.RecordId > 0 { + err := toggleCustomRule(this.Parent(), params.RecordId, params.IsOn) + if err != nil { + this.ErrorPage(err) + return + } + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/delete.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/delete.go new file mode 100644 index 0000000..004fe45 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/delete.go @@ -0,0 +1,48 @@ +package apps + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" +) + +type DeleteAction struct { + actionutils.ParentAction +} + +func (this *DeleteAction) Init() { + this.Nav("httpdns", "app", "delete") +} + +func (this *DeleteAction) RunGet(params struct { + AppId int64 +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + app, err := findAppMap(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + httpdnsutils.AddAppTabbar(this.Parent(), app.GetString("name"), app.GetInt64("id"), "delete") + this.Data["app"] = app + domains, err := listDomainMaps(this.Parent(), app.GetInt64("id"), "") + if err != nil { + this.ErrorPage(err) + return + } + this.Data["domainCount"] = len(domains) + this.Show() +} + +func (this *DeleteAction) RunPost(params struct { + AppId int64 +}) { + if params.AppId > 0 { + err := deleteAppByID(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + } + + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/domains.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/domains.go new file mode 100644 index 0000000..576de1a --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/domains.go @@ -0,0 +1,38 @@ +package apps + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" +) + +type DomainsAction struct { + actionutils.ParentAction +} + +func (this *DomainsAction) Init() { + this.Nav("httpdns", "app", "domains") +} + +func (this *DomainsAction) RunGet(params struct { + AppId int64 +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + app, err := findAppMap(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + + // 构建顶部 tabbar + httpdnsutils.AddAppTabbar(this.Parent(), app.GetString("name"), params.AppId, "domains") + + domains, err := listDomainMaps(this.Parent(), app.GetInt64("id"), "") + if err != nil { + this.ErrorPage(err) + return + } + + this.Data["app"] = app + this.Data["domains"] = domains + this.Show() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/domainsCreatePopup.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/domainsCreatePopup.go new file mode 100644 index 0000000..44cc478 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/domainsCreatePopup.go @@ -0,0 +1,44 @@ +package apps + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/iwind/TeaGo/actions" +) + +type DomainsCreatePopupAction struct { + actionutils.ParentAction +} + +func (this *DomainsCreatePopupAction) Init() { + this.Nav("", "", "") +} + +func (this *DomainsCreatePopupAction) RunGet(params struct { + AppId int64 +}) { + app, err := findAppMap(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + this.Data["app"] = app + this.Show() +} + +func (this *DomainsCreatePopupAction) RunPost(params struct { + AppId int64 + Domain string + + Must *actions.Must + CSRF *actionutils.CSRF +}) { + params.Must.Field("appId", params.AppId).Gt(0, "请选择应用") + params.Must.Field("domain", params.Domain).Require("请输入域名") + + err := createDomain(this.Parent(), params.AppId, params.Domain) + if err != nil { + this.ErrorPage(err) + return + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/domainsDelete.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/domainsDelete.go new file mode 100644 index 0000000..67042c3 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/domainsDelete.go @@ -0,0 +1,20 @@ +package apps + +import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + +type DomainsDeleteAction struct { + actionutils.ParentAction +} + +func (this *DomainsDeleteAction) RunPost(params struct { + DomainId int64 +}) { + if params.DomainId > 0 { + err := deleteDomain(this.Parent(), params.DomainId) + if err != nil { + this.ErrorPage(err) + return + } + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/formatters.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/formatters.go new file mode 100644 index 0000000..5c52256 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/formatters.go @@ -0,0 +1,92 @@ +package apps + +import ( + "strconv" + "strings" + + "github.com/iwind/TeaGo/maps" +) + +func maskSecret(secret string) string { + secret = strings.TrimSpace(secret) + if len(secret) < 4 { + return "******" + } + + prefix := "" + for i := 0; i < len(secret); i++ { + if secret[i] == '_' { + prefix = secret[:i+1] + break + } + } + if len(prefix) == 0 { + prefix = secret[:2] + } + + if len(secret) <= 8 { + return prefix + "xxxx" + } + return prefix + "xxxxxxxx" + secret[len(secret)-4:] +} + +func buildLineText(record maps.Map) string { + parts := []string{} + if strings.TrimSpace(record.GetString("lineScope")) == "overseas" { + parts = append(parts, + strings.TrimSpace(record.GetString("lineContinent")), + strings.TrimSpace(record.GetString("lineCountry")), + ) + } else { + parts = append(parts, + strings.TrimSpace(record.GetString("lineCarrier")), + strings.TrimSpace(record.GetString("lineRegion")), + strings.TrimSpace(record.GetString("lineProvince")), + ) + } + + finalParts := make([]string, 0, len(parts)) + for _, part := range parts { + if len(part) == 0 || part == "默认" { + continue + } + finalParts = append(finalParts, part) + } + if len(finalParts) == 0 { + return "默认" + } + return strings.Join(finalParts, " / ") +} + +func buildRecordValueText(record maps.Map) string { + values, ok := record["recordValues"].([]maps.Map) + if !ok || len(values) == 0 { + return "-" + } + + weightEnabled := record.GetBool("weightEnabled") + defaultType := strings.ToUpper(strings.TrimSpace(record.GetString("recordType"))) + parts := make([]string, 0, len(values)) + for _, item := range values { + value := strings.TrimSpace(item.GetString("value")) + if len(value) == 0 { + continue + } + recordType := strings.ToUpper(strings.TrimSpace(item.GetString("type"))) + if len(recordType) == 0 { + recordType = defaultType + } + if recordType != "A" && recordType != "AAAA" { + recordType = "A" + } + part := recordType + " " + value + if weightEnabled { + part += "(" + strconv.Itoa(item.GetInt("weight")) + ")" + } + parts = append(parts, part) + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, ", ") +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/index.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/index.go new file mode 100644 index 0000000..2a2f742 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/index.go @@ -0,0 +1,29 @@ +package apps + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" +) + +type IndexAction struct { + actionutils.ParentAction +} + +func (this *IndexAction) Init() { + this.Nav("httpdns", "app", "") +} + +func (this *IndexAction) RunGet(params struct { + Keyword string +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + this.Data["keyword"] = params.Keyword + apps, err := listAppMaps(this.Parent(), params.Keyword) + if err != nil { + this.ErrorPage(err) + return + } + this.Data["apps"] = apps + this.Data["page"] = "" + this.Show() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/init.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/init.go new file mode 100644 index 0000000..fdc2e25 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/init.go @@ -0,0 +1,38 @@ +package apps + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/configloaders" + "github.com/TeaOSLab/EdgeAdmin/internal/web/helpers" + "github.com/iwind/TeaGo" +) + +func init() { + TeaGo.BeforeStart(func(server *TeaGo.Server) { + server. + Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)). + Data("teaMenu", "httpdns"). + Data("teaSubMenu", "app"). + Prefix("/httpdns/apps"). + Get("", new(IndexAction)). + Get("/app", new(AppAction)). + Get("/sdk", new(SdkAction)). + GetPost("/sdk/upload", new(SdkUploadAction)). + Post("/sdk/upload/delete", new(SdkUploadDeleteAction)). + Get("/sdk/check", new(SdkCheckAction)). + Get("/sdk/download", new(SdkDownloadAction)). + Get("/sdk/doc", new(SdkDocAction)). + GetPost("/app/settings", new(AppSettingsAction)). + Post("/app/settings/toggleSignEnabled", new(AppSettingsToggleSignEnabledAction)). + Post("/app/settings/resetSignSecret", new(AppSettingsResetSignSecretAction)). + Get("/domains", new(DomainsAction)). + Get("/customRecords", new(CustomRecordsAction)). + GetPost("/create", new(CreateAction)). + GetPost("/delete", new(DeleteAction)). + GetPost("/domains/createPopup", new(DomainsCreatePopupAction)). + Post("/domains/delete", new(DomainsDeleteAction)). + GetPost("/customRecords/createPopup", new(CustomRecordsCreatePopupAction)). + Post("/customRecords/delete", new(CustomRecordsDeleteAction)). + Post("/customRecords/toggle", new(CustomRecordsToggleAction)). + EndAll() + }) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/rpc_helpers.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/rpc_helpers.go new file mode 100644 index 0000000..d8832af --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/rpc_helpers.go @@ -0,0 +1,396 @@ +package apps + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" + timeutil "github.com/iwind/TeaGo/utils/time" +) + +func listAppMaps(parent *actionutils.ParentAction, keyword string) ([]maps.Map, error) { + clusterNameMap, err := loadHTTPDNSClusterNameMap(parent) + if err != nil { + return nil, err + } + userMapByID, err := loadHTTPDNSUserMap(parent) + if err != nil { + return nil, err + } + + resp, err := parent.RPC().HTTPDNSAppRPC().ListHTTPDNSApps(parent.AdminContext(), &pb.ListHTTPDNSAppsRequest{ + Offset: 0, + Size: 10_000, + Keyword: strings.TrimSpace(keyword), + }) + if err != nil { + return nil, err + } + + result := make([]maps.Map, 0, len(resp.GetApps())) + for _, app := range resp.GetApps() { + domainResp, err := parent.RPC().HTTPDNSDomainRPC().ListHTTPDNSDomainsWithAppId(parent.AdminContext(), &pb.ListHTTPDNSDomainsWithAppIdRequest{ + AppDbId: app.GetId(), + }) + if err != nil { + return nil, err + } + + result = append(result, appPBToMap(app, int64(len(domainResp.GetDomains())), clusterNameMap, userMapByID)) + } + + return result, nil +} + +func findAppMap(parent *actionutils.ParentAction, appDbId int64) (maps.Map, error) { + clusterNameMap, err := loadHTTPDNSClusterNameMap(parent) + if err != nil { + return nil, err + } + userMapByID, err := loadHTTPDNSUserMap(parent) + if err != nil { + return nil, err + } + + if appDbId > 0 { + resp, err := parent.RPC().HTTPDNSAppRPC().FindHTTPDNSApp(parent.AdminContext(), &pb.FindHTTPDNSAppRequest{ + AppDbId: appDbId, + }) + if err != nil { + return nil, err + } + if resp.GetApp() != nil { + domainResp, err := parent.RPC().HTTPDNSDomainRPC().ListHTTPDNSDomainsWithAppId(parent.AdminContext(), &pb.ListHTTPDNSDomainsWithAppIdRequest{ + AppDbId: appDbId, + }) + if err != nil { + return nil, err + } + return appPBToMap(resp.GetApp(), int64(len(domainResp.GetDomains())), clusterNameMap, userMapByID), nil + } + } + + apps, err := listAppMaps(parent, "") + if err != nil { + return nil, err + } + if len(apps) == 0 { + return maps.Map{ + "id": int64(0), + "name": "", + "appId": "", + }, nil + } + return apps[0], nil +} + +func createApp(parent *actionutils.ParentAction, name string, clusterIdsJSON []byte) (int64, error) { + newAppId := "app" + strconv.FormatInt(time.Now().UnixNano()%1_000_000_000_000, 36) + resp, err := parent.RPC().HTTPDNSAppRPC().CreateHTTPDNSApp(parent.AdminContext(), &pb.CreateHTTPDNSAppRequest{ + Name: strings.TrimSpace(name), + AppId: newAppId, + ClusterIdsJSON: clusterIdsJSON, + IsOn: true, + SignEnabled: true, + }) + if err != nil { + return 0, err + } + return resp.GetAppDbId(), nil +} + +func deleteAppByID(parent *actionutils.ParentAction, appDbId int64) error { + _, err := parent.RPC().HTTPDNSAppRPC().DeleteHTTPDNSApp(parent.AdminContext(), &pb.DeleteHTTPDNSAppRequest{ + AppDbId: appDbId, + }) + return err +} + +func updateAppSettings(parent *actionutils.ParentAction, appDbId int64, name string, clusterIdsJSON []byte, isOn bool, userId int64) error { + _, err := parent.RPC().HTTPDNSAppRPC().UpdateHTTPDNSApp(parent.AdminContext(), &pb.UpdateHTTPDNSAppRequest{ + AppDbId: appDbId, + Name: strings.TrimSpace(name), + ClusterIdsJSON: clusterIdsJSON, + IsOn: isOn, + UserId: userId, + }) + return err +} + +func updateAppSignEnabled(parent *actionutils.ParentAction, appDbId int64, signEnabled bool) error { + _, err := parent.RPC().HTTPDNSAppRPC().UpdateHTTPDNSAppSignEnabled(parent.AdminContext(), &pb.UpdateHTTPDNSAppSignEnabledRequest{ + AppDbId: appDbId, + SignEnabled: signEnabled, + }) + return err +} + +func resetAppSignSecret(parent *actionutils.ParentAction, appDbId int64) (*pb.ResetHTTPDNSAppSignSecretResponse, error) { + return parent.RPC().HTTPDNSAppRPC().ResetHTTPDNSAppSignSecret(parent.AdminContext(), &pb.ResetHTTPDNSAppSignSecretRequest{ + AppDbId: appDbId, + }) +} + +func listDomainMaps(parent *actionutils.ParentAction, appDbId int64, keyword string) ([]maps.Map, error) { + resp, err := parent.RPC().HTTPDNSDomainRPC().ListHTTPDNSDomainsWithAppId(parent.AdminContext(), &pb.ListHTTPDNSDomainsWithAppIdRequest{ + AppDbId: appDbId, + Keyword: strings.TrimSpace(keyword), + }) + if err != nil { + return nil, err + } + + result := make([]maps.Map, 0, len(resp.GetDomains())) + for _, domain := range resp.GetDomains() { + result = append(result, maps.Map{ + "id": domain.GetId(), + "name": domain.GetDomain(), + "isOn": domain.GetIsOn(), + "customRecordCount": domain.GetRuleCount(), + }) + } + return result, nil +} + +func createDomain(parent *actionutils.ParentAction, appDbId int64, domain string) error { + _, err := parent.RPC().HTTPDNSDomainRPC().CreateHTTPDNSDomain(parent.AdminContext(), &pb.CreateHTTPDNSDomainRequest{ + AppDbId: appDbId, + Domain: strings.TrimSpace(domain), + IsOn: true, + }) + return err +} + +func deleteDomain(parent *actionutils.ParentAction, domainId int64) error { + _, err := parent.RPC().HTTPDNSDomainRPC().DeleteHTTPDNSDomain(parent.AdminContext(), &pb.DeleteHTTPDNSDomainRequest{ + DomainId: domainId, + }) + return err +} + +func findDomainMap(domains []maps.Map, domainID int64) maps.Map { + if len(domains) == 0 { + return maps.Map{} + } + if domainID <= 0 { + return domains[0] + } + for _, domain := range domains { + if domain.GetInt64("id") == domainID { + return domain + } + } + return domains[0] +} + +func listCustomRuleMaps(parent *actionutils.ParentAction, domainId int64) ([]maps.Map, error) { + resp, err := parent.RPC().HTTPDNSRuleRPC().ListHTTPDNSCustomRulesWithDomainId(parent.AdminContext(), &pb.ListHTTPDNSCustomRulesWithDomainIdRequest{ + DomainId: domainId, + }) + if err != nil { + return nil, err + } + + result := make([]maps.Map, 0, len(resp.GetRules())) + for _, rule := range resp.GetRules() { + recordValues := make([]maps.Map, 0, len(rule.GetRecords())) + recordType := "A" + weightEnabled := false + for _, record := range rule.GetRecords() { + if len(recordType) == 0 { + recordType = strings.ToUpper(strings.TrimSpace(record.GetRecordType())) + } + if record.GetWeight() > 0 && record.GetWeight() != 100 { + weightEnabled = true + } + recordValues = append(recordValues, maps.Map{ + "type": strings.ToUpper(strings.TrimSpace(record.GetRecordType())), + "value": record.GetRecordValue(), + "weight": record.GetWeight(), + }) + } + if len(recordValues) == 0 { + recordValues = append(recordValues, maps.Map{ + "type": "A", + "value": "", + "weight": 100, + }) + } + + item := maps.Map{ + "id": rule.GetId(), + "lineScope": rule.GetLineScope(), + "lineCarrier": defaultLineField(rule.GetLineCarrier()), + "lineRegion": defaultLineField(rule.GetLineRegion()), + "lineProvince": defaultLineField(rule.GetLineProvince()), + "lineContinent": defaultLineField(rule.GetLineContinent()), + "lineCountry": defaultLineField(rule.GetLineCountry()), + "ruleName": rule.GetRuleName(), + "recordType": recordType, + "recordValues": recordValues, + "weightEnabled": weightEnabled, + "ttl": rule.GetTtl(), + "isOn": rule.GetIsOn(), + "updatedAt": formatDateTime(rule.GetUpdatedAt()), + } + item["lineText"] = buildLineText(item) + item["recordValueText"] = buildRecordValueText(item) + result = append(result, item) + } + return result, nil +} + +func createCustomRule(parent *actionutils.ParentAction, rule *pb.HTTPDNSCustomRule) (int64, error) { + resp, err := parent.RPC().HTTPDNSRuleRPC().CreateHTTPDNSCustomRule(parent.AdminContext(), &pb.CreateHTTPDNSCustomRuleRequest{ + Rule: rule, + }) + if err != nil { + return 0, err + } + return resp.GetRuleId(), nil +} + +func updateCustomRule(parent *actionutils.ParentAction, rule *pb.HTTPDNSCustomRule) error { + _, err := parent.RPC().HTTPDNSRuleRPC().UpdateHTTPDNSCustomRule(parent.AdminContext(), &pb.UpdateHTTPDNSCustomRuleRequest{ + Rule: rule, + }) + return err +} + +func deleteCustomRule(parent *actionutils.ParentAction, ruleId int64) error { + _, err := parent.RPC().HTTPDNSRuleRPC().DeleteHTTPDNSCustomRule(parent.AdminContext(), &pb.DeleteHTTPDNSCustomRuleRequest{ + RuleId: ruleId, + }) + return err +} + +func toggleCustomRule(parent *actionutils.ParentAction, ruleId int64, isOn bool) error { + _, err := parent.RPC().HTTPDNSRuleRPC().UpdateHTTPDNSCustomRuleStatus(parent.AdminContext(), &pb.UpdateHTTPDNSCustomRuleStatusRequest{ + RuleId: ruleId, + IsOn: isOn, + }) + return err +} + +func appPBToMap(app *pb.HTTPDNSApp, domainCount int64, clusterMapByID map[int64]maps.Map, userMapByID map[int64]maps.Map) maps.Map { + signSecret := app.GetSignSecret() + + // 读取集群 ID 列表 + var clusterIds []int64 + if len(app.GetClusterIdsJSON()) > 0 { + _ = json.Unmarshal(app.GetClusterIdsJSON(), &clusterIds) + } + + // 构建集群映射列表 + var clusterMaps []maps.Map + for _, cid := range clusterIds { + cm := clusterMapByID[cid] + if cm == nil { + cm = maps.Map{"id": cid, "name": "", "apiAddress": ""} + } + clusterMaps = append(clusterMaps, cm) + } + + var userMap maps.Map + if app.GetUserId() > 0 { + userMap = userMapByID[app.GetUserId()] + if userMap == nil { + userMap = maps.Map{ + "id": app.GetUserId(), + "fullname": "用户#" + strconv.FormatInt(app.GetUserId(), 10), + "username": "-", + } + } + } + + return maps.Map{ + "id": app.GetId(), + "name": app.GetName(), + "appId": app.GetAppId(), + "clusterIds": clusterIds, + "clusters": clusterMaps, + "userId": app.GetUserId(), + "user": userMap, + "isOn": app.GetIsOn(), + "domainCount": domainCount, + "sniPolicyText": "隐匿 SNI", + "signEnabled": app.GetSignEnabled(), + "signSecretPlain": signSecret, + "signSecretMasked": maskSecret(signSecret), + "signSecretUpdated": formatDateTime(app.GetSignUpdatedAt()), + } +} + +func loadHTTPDNSClusterNameMap(parent *actionutils.ParentAction) (map[int64]maps.Map, error) { + resp, err := parent.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(parent.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if err != nil { + return nil, err + } + + result := map[int64]maps.Map{} + for _, cluster := range resp.GetClusters() { + port := "443" + if rawTLS := cluster.GetTlsPolicyJSON(); len(rawTLS) > 0 { + tlsConfig := maps.Map{} + if err := json.Unmarshal(rawTLS, &tlsConfig); err == nil { + if listenRaw := tlsConfig.Get("listen"); listenRaw != nil { + var listenAddresses []maps.Map + if data, err := json.Marshal(listenRaw); err == nil { + if err := json.Unmarshal(data, &listenAddresses); err == nil { + if len(listenAddresses) > 0 && len(listenAddresses[0].GetString("portRange")) > 0 { + port = listenAddresses[0].GetString("portRange") + } + } + } + } + } + } + apiAddress := "https://" + cluster.GetServiceDomain() + ":" + port + + result[cluster.GetId()] = maps.Map{ + "id": cluster.GetId(), + "name": cluster.GetName(), + "apiAddress": apiAddress, + } + } + return result, nil +} + +func loadHTTPDNSUserMap(parent *actionutils.ParentAction) (map[int64]maps.Map, error) { + resp, err := parent.RPC().UserRPC().ListEnabledUsers(parent.AdminContext(), &pb.ListEnabledUsersRequest{ + Offset: 0, + Size: 10_000, + }) + if err != nil { + return nil, err + } + + result := map[int64]maps.Map{} + for _, user := range resp.GetUsers() { + result[user.GetId()] = maps.Map{ + "id": user.GetId(), + "fullname": user.GetFullname(), + "username": user.GetUsername(), + } + } + return result, nil +} + +func defaultLineField(value string) string { + value = strings.TrimSpace(value) + if len(value) == 0 { + return "默认" + } + return value +} + +func formatDateTime(ts int64) string { + if ts <= 0 { + return "" + } + return timeutil.FormatTime("Y-m-d H:i:s", ts) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk.go new file mode 100644 index 0000000..95a2509 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk.go @@ -0,0 +1,30 @@ +package apps + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" +) + +type SdkAction struct { + actionutils.ParentAction +} + +func (this *SdkAction) Init() { + this.Nav("httpdns", "app", "sdk") +} + +func (this *SdkAction) RunGet(params struct { + AppId int64 +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + app, err := findAppMap(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + + httpdnsutils.AddAppTabbar(this.Parent(), app.GetString("name"), params.AppId, "sdk") + this.Data["app"] = app + this.Show() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_check.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_check.go new file mode 100644 index 0000000..a8f2a3c --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_check.go @@ -0,0 +1,68 @@ +package apps + +import ( + "net/url" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" +) + +type SdkCheckAction struct { + actionutils.ParentAction +} + +func (this *SdkCheckAction) Init() { + this.Nav("", "", "") +} + +func (this *SdkCheckAction) RunGet(params struct { + Platform string + Version string + Type string +}) { + platform, _, _, filename, err := resolveSDKPlatform(params.Platform) + if err != nil { + this.Data["exists"] = false + this.Data["message"] = err.Error() + this.Success() + return + } + + version := strings.TrimSpace(params.Version) + t := strings.ToLower(strings.TrimSpace(params.Type)) + if t == "doc" { + docPath := findUploadedSDKDocPath(platform, version) + if len(docPath) == 0 { + this.Data["exists"] = false + this.Data["message"] = "当前平台/版本尚未上传集成文档" + this.Success() + return + } + + downloadURL := "/httpdns/apps/sdk/doc?platform=" + url.QueryEscape(platform) + if len(version) > 0 { + downloadURL += "&version=" + url.QueryEscape(version) + } + this.Data["exists"] = true + this.Data["url"] = downloadURL + this.Success() + return + } + + archivePath := findSDKArchivePath(filename, version) + if len(archivePath) == 0 { + this.Data["exists"] = false + this.Data["message"] = "当前平台/版本尚未上传 SDK 安装包" + this.Success() + return + } + + downloadURL := "/httpdns/apps/sdk/download?platform=" + url.QueryEscape(platform) + if len(version) > 0 { + downloadURL += "&version=" + url.QueryEscape(version) + } + downloadURL += "&raw=1" + this.Data["exists"] = true + this.Data["url"] = downloadURL + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_doc.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_doc.go new file mode 100644 index 0000000..31b8ab1 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_doc.go @@ -0,0 +1,55 @@ +package apps + +import ( + "net/url" + "os" + "path/filepath" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" +) + +type SdkDocAction struct { + actionutils.ParentAction +} + +func (this *SdkDocAction) Init() { + this.Nav("", "", "") +} + +func (this *SdkDocAction) RunGet(params struct { + Platform string + Version string +}) { + platform, _, _, _, err := resolveSDKPlatform(params.Platform) + if err != nil { + this.Data["exists"] = false + this.Data["message"] = err.Error() + this.Success() + return + } + + docPath := findUploadedSDKDocPath(platform, params.Version) + if len(docPath) == 0 { + this.Data["exists"] = false + this.Data["message"] = "当前平台/版本尚未上传集成文档" + this.Success() + return + } + + data, err := os.ReadFile(docPath) + if err != nil || len(data) == 0 { + this.Data["exists"] = false + this.Data["message"] = "读取集成文档失败" + this.Success() + return + } + + downloadName := filepath.Base(docPath) + if len(downloadName) == 0 || downloadName == "." || downloadName == string(filepath.Separator) { + downloadName = "sdk-doc.md" + } + + this.AddHeader("Content-Type", "text/markdown; charset=utf-8") + this.AddHeader("Content-Disposition", "attachment; filename=\"sdk-doc.md\"; filename*=UTF-8''"+url.PathEscape(downloadName)) + _, _ = this.ResponseWriter.Write(data) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_download.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_download.go new file mode 100644 index 0000000..b6d19e1 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_download.go @@ -0,0 +1,66 @@ +package apps + +import ( + "io" + "net/url" + "os" + "path/filepath" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" +) + +type SdkDownloadAction struct { + actionutils.ParentAction +} + +func (this *SdkDownloadAction) Init() { + this.Nav("", "", "") +} + +func (this *SdkDownloadAction) RunGet(params struct { + Platform string + Version string + Raw int +}) { + _, _, _, filename, err := resolveSDKPlatform(params.Platform) + if err != nil { + this.Data["exists"] = false + this.Data["message"] = err.Error() + this.Success() + return + } + + archivePath := findSDKArchivePath(filename, params.Version) + if len(archivePath) == 0 { + this.Data["exists"] = false + this.Data["message"] = "当前平台/版本尚未上传 SDK 安装包" + this.Success() + return + } + + fp, err := os.Open(archivePath) + if err != nil { + this.Data["exists"] = false + this.Data["message"] = "打开 SDK 安装包失败: " + err.Error() + this.Success() + return + } + defer func() { + _ = fp.Close() + }() + + downloadName := filepath.Base(archivePath) + if len(downloadName) == 0 || downloadName == "." || downloadName == string(filepath.Separator) { + downloadName = filename + } + + if params.Raw == 1 { + this.AddHeader("Content-Type", "application/octet-stream") + this.AddHeader("X-SDK-Filename", downloadName) + } else { + this.AddHeader("Content-Type", "application/zip") + this.AddHeader("Content-Disposition", "attachment; filename=\"sdk-download\"; filename*=UTF-8''"+url.PathEscape(downloadName)) + } + this.AddHeader("X-Accel-Buffering", "no") + _, _ = io.Copy(this.ResponseWriter, fp) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_helpers.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_helpers.go new file mode 100644 index 0000000..236d5c9 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_helpers.go @@ -0,0 +1,262 @@ +package apps + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/iwind/TeaGo/Tea" +) + +type sdkUploadMeta struct { + Platform string `json:"platform"` + Version string `json:"version"` + FileType string `json:"fileType"` // sdk | doc + Filename string `json:"filename"` + UpdatedAt int64 `json:"updatedAt"` +} + +type sdkUploadMetaRecord struct { + Meta sdkUploadMeta + Dir string + FilePath string +} + +func sdkUploadMetaFilename(platform string, version string, fileType string) string { + platform = strings.ToLower(strings.TrimSpace(platform)) + version = strings.TrimSpace(version) + fileType = strings.ToLower(strings.TrimSpace(fileType)) + return ".httpdns-sdk-meta-" + platform + "-v" + version + "-" + fileType + ".json" +} + +func isSDKUploadMetaFile(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + return strings.HasPrefix(name, ".httpdns-sdk-meta-") && strings.HasSuffix(name, ".json") +} + +func parseSDKPlatformFromDownloadFilename(downloadFilename string) string { + name := strings.ToLower(strings.TrimSpace(downloadFilename)) + if !strings.HasPrefix(name, "httpdns-sdk-") || !strings.HasSuffix(name, ".zip") { + return "" + } + + platform := strings.TrimSuffix(strings.TrimPrefix(name, "httpdns-sdk-"), ".zip") + switch platform { + case "android", "ios", "flutter": + return platform + default: + return "" + } +} + +func listSDKUploadMetaRecords() []sdkUploadMetaRecord { + type wrapped struct { + record sdkUploadMetaRecord + modTime time.Time + } + + byKey := map[string]wrapped{} + for _, dir := range sdkUploadSearchDirs() { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !isSDKUploadMetaFile(name) { + continue + } + + metaPath := filepath.Join(dir, name) + data, err := os.ReadFile(metaPath) + if err != nil || len(data) == 0 { + continue + } + + var meta sdkUploadMeta + if err = json.Unmarshal(data, &meta); err != nil { + continue + } + + meta.Platform = strings.ToLower(strings.TrimSpace(meta.Platform)) + meta.Version = strings.TrimSpace(meta.Version) + meta.FileType = strings.ToLower(strings.TrimSpace(meta.FileType)) + meta.Filename = filepath.Base(strings.TrimSpace(meta.Filename)) + if len(meta.Platform) == 0 || len(meta.Version) == 0 || len(meta.Filename) == 0 { + continue + } + if meta.FileType != "sdk" && meta.FileType != "doc" { + continue + } + if strings.Contains(meta.Filename, "..") || strings.Contains(meta.Filename, "/") || strings.Contains(meta.Filename, "\\") { + continue + } + + filePath := filepath.Join(dir, meta.Filename) + fileStat, err := os.Stat(filePath) + if err != nil || fileStat.IsDir() || fileStat.Size() <= 0 { + continue + } + + metaStat, err := os.Stat(metaPath) + if err != nil { + continue + } + if meta.UpdatedAt <= 0 { + meta.UpdatedAt = metaStat.ModTime().Unix() + } + + key := meta.Platform + "|" + meta.Version + "|" + meta.FileType + current := wrapped{ + record: sdkUploadMetaRecord{ + Meta: meta, + Dir: dir, + FilePath: filePath, + }, + modTime: metaStat.ModTime(), + } + old, ok := byKey[key] + if !ok || + current.record.Meta.UpdatedAt > old.record.Meta.UpdatedAt || + (current.record.Meta.UpdatedAt == old.record.Meta.UpdatedAt && current.modTime.After(old.modTime)) || + (current.record.Meta.UpdatedAt == old.record.Meta.UpdatedAt && current.modTime.Equal(old.modTime) && current.record.FilePath > old.record.FilePath) { + byKey[key] = current + } + } + } + + result := make([]sdkUploadMetaRecord, 0, len(byKey)) + for _, item := range byKey { + result = append(result, item.record) + } + return result +} + +func findSDKUploadFileByMeta(platform string, version string, fileType string) string { + platform = strings.ToLower(strings.TrimSpace(platform)) + version = strings.TrimSpace(version) + fileType = strings.ToLower(strings.TrimSpace(fileType)) + if len(platform) == 0 || len(version) == 0 { + return "" + } + + for _, record := range listSDKUploadMetaRecords() { + if record.Meta.Platform == platform && record.Meta.Version == version && record.Meta.FileType == fileType { + return record.FilePath + } + } + return "" +} + +func findNewestSDKUploadFileByMeta(platform string, fileType string) string { + platform = strings.ToLower(strings.TrimSpace(platform)) + fileType = strings.ToLower(strings.TrimSpace(fileType)) + if len(platform) == 0 { + return "" + } + + var foundPath string + var foundUpdatedAt int64 + for _, record := range listSDKUploadMetaRecords() { + if record.Meta.Platform != platform || record.Meta.FileType != fileType { + continue + } + + if len(foundPath) == 0 || record.Meta.UpdatedAt > foundUpdatedAt || (record.Meta.UpdatedAt == foundUpdatedAt && record.FilePath > foundPath) { + foundPath = record.FilePath + foundUpdatedAt = record.Meta.UpdatedAt + } + } + return foundPath +} + +func sdkUploadDir() string { + dirs := sdkUploadDirs() + if len(dirs) > 0 { + return dirs[0] + } + return filepath.Clean(Tea.Root + "/data/httpdns/sdk") +} + +func sdkUploadDirs() []string { + candidates := []string{ + filepath.Clean(Tea.Root + "/../data/httpdns/sdk"), + filepath.Clean(Tea.Root + "/data/httpdns/sdk"), + filepath.Clean(Tea.Root + "/../edge-admin/data/httpdns/sdk"), + filepath.Clean(Tea.Root + "/../edge-user/data/httpdns/sdk"), + filepath.Clean(Tea.Root + "/../../data/httpdns/sdk"), + } + + results := make([]string, 0, len(candidates)) + seen := map[string]bool{} + for _, dir := range candidates { + if len(dir) == 0 || seen[dir] { + continue + } + seen[dir] = true + results = append(results, dir) + } + return results +} + +func sdkUploadSearchDirs() []string { + dirs := sdkUploadDirs() + results := make([]string, 0, len(dirs)) + for _, dir := range dirs { + stat, err := os.Stat(dir) + if err == nil && stat.IsDir() { + results = append(results, dir) + } + } + if len(results) == 0 { + results = append(results, sdkUploadDir()) + } + return results +} + +func resolveSDKPlatform(platform string) (key string, relativeDir string, readmeRelativePath string, downloadFilename string, err error) { + switch strings.ToLower(strings.TrimSpace(platform)) { + case "android": + return "android", "android", "android/README.md", "httpdns-sdk-android.zip", nil + case "ios": + return "ios", "ios", "ios/README.md", "httpdns-sdk-ios.zip", nil + case "flutter": + return "flutter", "flutter/new_httpdns", "flutter/new_httpdns/README.md", "httpdns-sdk-flutter.zip", nil + default: + return "", "", "", "", errors.New("不支持的平台,可选值:android、ios、flutter") + } +} + +func findSDKArchivePath(downloadFilename string, version string) string { + platform := parseSDKPlatformFromDownloadFilename(downloadFilename) + if len(platform) == 0 { + return "" + } + + normalizedVersion := strings.TrimSpace(version) + if len(normalizedVersion) > 0 { + return findSDKUploadFileByMeta(platform, normalizedVersion, "sdk") + } + return findNewestSDKUploadFileByMeta(platform, "sdk") +} + +func findUploadedSDKDocPath(platform string, version string) string { + platform = strings.ToLower(strings.TrimSpace(platform)) + if len(platform) == 0 { + return "" + } + + normalizedVersion := strings.TrimSpace(version) + if len(normalizedVersion) > 0 { + return findSDKUploadFileByMeta(platform, normalizedVersion, "doc") + } + return findNewestSDKUploadFileByMeta(platform, "doc") +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_upload.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_upload.go new file mode 100644 index 0000000..df5f2cb --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_upload.go @@ -0,0 +1,287 @@ +package apps + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/iwind/TeaGo/actions" +) + +const sdkUploadMaxFileSize = 20 * 1024 * 1024 // 20MB + +type SdkUploadAction struct { + actionutils.ParentAction +} + +func (this *SdkUploadAction) Init() { + this.Nav("httpdns", "app", "sdk") +} + +func (this *SdkUploadAction) RunGet(params struct { + AppId int64 +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + app, err := findAppMap(this.Parent(), params.AppId) + if err != nil { + this.ErrorPage(err) + return + } + + httpdnsutils.AddAppTabbar(this.Parent(), app.GetString("name"), params.AppId, "sdk") + + this.Data["app"] = app + this.Data["defaultVersion"] = "1.0.0" + this.Data["uploadedFiles"] = listUploadedSDKFiles() + this.Show() +} + +func (this *SdkUploadAction) RunPost(params struct { + AppId int64 + Platform string + Version string + SdkFile *actions.File + DocFile *actions.File + + Must *actions.Must +}) { + params.Must.Field("appId", params.AppId).Gt(0, "请选择应用") + + platform, _, _, _, err := resolveSDKPlatform(params.Platform) + if err != nil { + this.Fail(err.Error()) + return + } + + version, err := normalizeSDKVersion(params.Version) + if err != nil { + this.Fail(err.Error()) + return + } + + if params.SdkFile == nil && params.DocFile == nil { + this.Fail("请至少上传一个文件") + return + } + + uploadDir := sdkUploadDir() + if err = os.MkdirAll(uploadDir, 0755); err != nil { + this.Fail("创建上传目录失败: " + err.Error()) + return + } + + if params.SdkFile != nil { + if err = this.saveUploadedItem(uploadDir, platform, version, "sdk", params.SdkFile); err != nil { + this.Fail(err.Error()) + return + } + } + + if params.DocFile != nil { + if err = this.saveUploadedItem(uploadDir, platform, version, "doc", params.DocFile); err != nil { + this.Fail(err.Error()) + return + } + } + + this.Success() +} + +func (this *SdkUploadAction) saveUploadedItem(uploadDir string, platform string, version string, fileType string, file *actions.File) error { + expectedExt := ".md" + displayType := "集成文档" + if fileType == "sdk" { + expectedExt = ".zip" + displayType = "SDK 包" + } + + filename, err := normalizeUploadedFilename(file.Filename, expectedExt) + if err != nil { + return err + } + + if file.Size > sdkUploadMaxFileSize { + return errors.New(displayType + "文件不能超过 20MB") + } + + data, err := file.Read() + if err != nil { + return errors.New("读取" + displayType + "失败: " + err.Error()) + } + if len(data) == 0 { + return errors.New(displayType + "文件为空,请重新上传") + } + if len(data) > sdkUploadMaxFileSize { + return errors.New(displayType + "文件不能超过 20MB") + } + + if err = saveSDKUploadFile(uploadDir, filename, data); err != nil { + return errors.New("保存" + displayType + "失败: " + err.Error()) + } + + err = saveSDKUploadMetaRecord(uploadDir, sdkUploadMeta{ + Platform: platform, + Version: version, + FileType: fileType, + Filename: filename, + UpdatedAt: time.Now().Unix(), + }) + if err != nil { + return errors.New("保存上传元信息失败: " + err.Error()) + } + + return nil +} + +func normalizeSDKVersion(version string) (string, error) { + version = strings.TrimSpace(version) + if len(version) == 0 { + version = "1.0.0" + } + for _, c := range version { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-' { + continue + } + return "", errors.New("版本号格式不正确") + } + return version, nil +} + +func normalizeUploadedFilename(raw string, expectedExt string) (string, error) { + filename := filepath.Base(strings.TrimSpace(raw)) + if len(filename) == 0 || filename == "." || filename == string(filepath.Separator) { + return "", errors.New("文件名不能为空") + } + if strings.Contains(filename, "/") || strings.Contains(filename, "\\") || strings.Contains(filename, "..") { + return "", errors.New("文件名不合法") + } + + actualExt := strings.ToLower(filepath.Ext(filename)) + if actualExt != strings.ToLower(expectedExt) { + if expectedExt == ".zip" { + return "", errors.New("SDK 包仅支持 .zip 文件") + } + return "", errors.New("集成文档仅支持 .md 文件") + } + + return filename, nil +} + +func saveSDKUploadFile(baseDir string, filename string, data []byte) error { + targetPath := filepath.Join(baseDir, filename) + tmpPath := targetPath + ".tmp" + err := os.WriteFile(tmpPath, data, 0644) + if err != nil { + return err + } + return os.Rename(tmpPath, targetPath) +} + +func saveSDKUploadMetaRecord(baseDir string, meta sdkUploadMeta) error { + meta.Platform = strings.ToLower(strings.TrimSpace(meta.Platform)) + meta.Version = strings.TrimSpace(meta.Version) + meta.FileType = strings.ToLower(strings.TrimSpace(meta.FileType)) + meta.Filename = filepath.Base(strings.TrimSpace(meta.Filename)) + if len(meta.Platform) == 0 || len(meta.Version) == 0 || len(meta.FileType) == 0 || len(meta.Filename) == 0 { + return errors.New("上传元信息不完整") + } + + metaFilename := sdkUploadMetaFilename(meta.Platform, meta.Version, meta.FileType) + metaPath := filepath.Join(baseDir, metaFilename) + if data, err := os.ReadFile(metaPath); err == nil && len(data) > 0 { + var oldMeta sdkUploadMeta + if json.Unmarshal(data, &oldMeta) == nil { + oldFile := filepath.Base(strings.TrimSpace(oldMeta.Filename)) + if len(oldFile) > 0 && oldFile != meta.Filename { + _ = os.Remove(filepath.Join(baseDir, oldFile)) + } + } + } + + data, err := json.Marshal(meta) + if err != nil { + return err + } + + return saveSDKUploadFile(baseDir, metaFilename, data) +} + +func listUploadedSDKFiles() []map[string]interface{} { + type item struct { + Name string + Platform string + FileType string + Version string + SizeBytes int64 + UpdatedAt int64 + } + + items := make([]item, 0) + for _, record := range listSDKUploadMetaRecords() { + stat, err := os.Stat(record.FilePath) + if err != nil || stat.IsDir() { + continue + } + + fileType := "SDK 包" + if record.Meta.FileType == "doc" { + fileType = "集成文档" + } + + items = append(items, item{ + Name: filepath.Base(record.FilePath), + Platform: record.Meta.Platform, + FileType: fileType, + Version: record.Meta.Version, + SizeBytes: stat.Size(), + UpdatedAt: stat.ModTime().Unix(), + }) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].UpdatedAt == items[j].UpdatedAt { + return items[i].Name > items[j].Name + } + return items[i].UpdatedAt > items[j].UpdatedAt + }) + + result := make([]map[string]interface{}, 0, len(items)) + for _, it := range items { + result = append(result, map[string]interface{}{ + "name": it.Name, + "platform": it.Platform, + "fileType": it.FileType, + "version": it.Version, + "sizeText": formatSDKFileSize(it.SizeBytes), + "updatedAt": time.Unix(it.UpdatedAt, 0).Format("2006-01-02 15:04:05"), + }) + } + return result +} + +func formatSDKFileSize(size int64) string { + if size < 1024 { + return strconv.FormatInt(size, 10) + " B" + } + + sizeKB := float64(size) / 1024 + if sizeKB < 1024 { + return strconv.FormatFloat(sizeKB, 'f', 1, 64) + " KB" + } + + sizeMB := sizeKB / 1024 + if sizeMB < 1024 { + return strconv.FormatFloat(sizeMB, 'f', 1, 64) + " MB" + } + + sizeGB := sizeMB / 1024 + return strconv.FormatFloat(sizeGB, 'f', 1, 64) + " GB" +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_upload_delete.go b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_upload_delete.go new file mode 100644 index 0000000..4809474 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/apps/sdk_upload_delete.go @@ -0,0 +1,90 @@ +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" +) + +type SdkUploadDeleteAction struct { + actionutils.ParentAction +} + +func (this *SdkUploadDeleteAction) Init() { + this.Nav("httpdns", "app", "sdk") +} + +func (this *SdkUploadDeleteAction) RunPost(params struct { + AppId int64 + Filename string +}) { + if params.AppId <= 0 { + this.Fail("请选择应用") + return + } + + filename := strings.TrimSpace(params.Filename) + if len(filename) == 0 { + this.Fail("文件名不能为空") + return + } + if strings.Contains(filename, "/") || strings.Contains(filename, "\\") || strings.Contains(filename, "..") { + this.Fail("文件名不合法") + return + } + lowName := strings.ToLower(filename) + if !strings.HasSuffix(lowName, ".zip") && !strings.HasSuffix(lowName, ".md") { + this.Fail("仅允许删除 .zip 或 .md 文件") + return + } + + for _, dir := range sdkUploadDirs() { + fullPath := filepath.Join(dir, filename) + stat, err := os.Stat(fullPath) + if err != nil || stat.IsDir() { + continue + } + if err = os.Remove(fullPath); err != nil { + this.Fail("删除失败: " + err.Error()) + return + } + } + + // 删除引用该文件的元数据 + for _, dir := range sdkUploadDirs() { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if !isSDKUploadMetaFile(entry.Name()) { + continue + } + + metaPath := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(metaPath) + if err != nil || len(data) == 0 { + continue + } + + var meta sdkUploadMeta + if err = json.Unmarshal(data, &meta); err != nil { + continue + } + if filepath.Base(strings.TrimSpace(meta.Filename)) != filename { + continue + } + + _ = os.Remove(metaPath) + } + } + + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/certs.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/certs.go new file mode 100644 index 0000000..f0d80ca --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/certs.go @@ -0,0 +1,17 @@ +package clusters + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" +) + +type CertsAction struct { + actionutils.ParentAction +} + +func (this *CertsAction) Init() { + this.Nav("httpdns", "cluster", "certs") +} + +func (this *CertsAction) RunGet(params struct{}) { + this.RedirectURL("/httpdns/clusters") +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/checkPorts.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/checkPorts.go new file mode 100644 index 0000000..63edfa2 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/checkPorts.go @@ -0,0 +1,10 @@ +package clusters +import ( +"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" +"github.com/iwind/TeaGo/maps" +) +type CheckPortsAction struct { actionutils.ParentAction } +func (this *CheckPortsAction) RunPost(params struct{ NodeId int64 }) { +this.Data["results"] = []maps.Map{} +this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster.go new file mode 100644 index 0000000..03dbd64 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster.go @@ -0,0 +1,97 @@ +package clusters + +import ( + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/iwind/TeaGo/maps" +) + +type ClusterAction struct { + actionutils.ParentAction +} + +func (this *ClusterAction) Init() { + this.Nav("httpdns", "cluster", "index") +} + +func (this *ClusterAction) RunGet(params struct { + ClusterId int64 + InstalledState int + ActiveState int + Keyword string +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + cluster, err := findClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + + httpdnsutils.AddClusterTabbar(this.Parent(), cluster.GetString("name"), params.ClusterId, "node") + + nodes, err := listNodeMaps(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + nodes = filterClusterNodes(nodes, params.InstalledState, params.ActiveState, params.Keyword) + + this.Data["clusterId"] = params.ClusterId + this.Data["cluster"] = cluster + this.Data["installState"] = params.InstalledState + this.Data["activeState"] = params.ActiveState + this.Data["keyword"] = params.Keyword + this.Data["nodes"] = nodes + this.Data["hasNodes"] = len(nodes) > 0 + this.Data["page"] = "" + this.Show() +} + +func filterClusterNodes(nodes []maps.Map, installedState int, activeState int, keyword string) []maps.Map { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + + result := make([]maps.Map, 0, len(nodes)) + for _, node := range nodes { + isInstalled := node.GetBool("isInstalled") + if installedState == 1 && !isInstalled { + continue + } + if installedState == 2 && isInstalled { + continue + } + + status := node.GetMap("status") + isOnline := node.GetBool("isOn") && node.GetBool("isUp") && status.GetBool("isActive") + if activeState == 1 && !isOnline { + continue + } + if activeState == 2 && isOnline { + continue + } + + if len(keyword) > 0 { + hit := strings.Contains(strings.ToLower(node.GetString("name")), keyword) + if !hit { + ipAddresses, ok := node["ipAddresses"].([]maps.Map) + if ok { + for _, ipAddr := range ipAddresses { + if strings.Contains(strings.ToLower(ipAddr.GetString("ip")), keyword) { + hit = true + break + } + } + } + } + if !hit { + continue + } + } + + result = append(result, node) + } + + return result +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/index.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/index.go new file mode 100644 index 0000000..d34840b --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/index.go @@ -0,0 +1,73 @@ +package node + +import ( + "strings" + "time" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + timeutil "github.com/iwind/TeaGo/utils/time" +) + +type IndexAction struct { + actionutils.ParentAction +} + +func (this *IndexAction) Init() { + this.Nav("", "node", "node") + this.SecondMenu("nodes") +} + +func (this *IndexAction) RunGet(params struct { + ClusterId int64 + NodeId int64 +}) { + node, err := findHTTPDNSNodeMap(this.Parent(), params.NodeId) + if err != nil { + this.ErrorPage(err) + return + } + cluster, err := findHTTPDNSClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + + this.Data["clusterId"] = params.ClusterId + this.Data["nodeId"] = params.NodeId + this.Data["node"] = node + this.Data["currentCluster"] = cluster + + status := node.GetMap("status") + updatedAt := status.GetInt64("updatedAt") + nodeDatetime := "" + nodeTimeDiff := int64(0) + if updatedAt > 0 { + nodeDatetime = timeutil.FormatTime("Y-m-d H:i:s", updatedAt) + nodeTimeDiff = time.Now().Unix() - updatedAt + if nodeTimeDiff < 0 { + nodeTimeDiff = -nodeTimeDiff + } + } + this.Data["nodeDatetime"] = nodeDatetime + this.Data["nodeTimeDiff"] = nodeTimeDiff + + osName := strings.TrimSpace(status.GetString("os")) + if len(osName) > 0 { + checkVersionResp, err := this.RPC().HTTPDNSNodeRPC().CheckHTTPDNSNodeLatestVersion(this.AdminContext(), &pb.CheckHTTPDNSNodeLatestVersionRequest{ + Os: osName, + Arch: strings.TrimSpace(status.GetString("arch")), + CurrentVersion: strings.TrimSpace(status.GetString("buildVersion")), + }) + if err != nil { + this.ErrorPage(err) + return + } + this.Data["shouldUpgrade"] = checkVersionResp.GetHasNewVersion() + this.Data["newVersion"] = checkVersionResp.GetNewVersion() + } else { + this.Data["shouldUpgrade"] = false + this.Data["newVersion"] = "" + } + this.Show() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/install.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/install.go new file mode 100644 index 0000000..8da9713 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/install.go @@ -0,0 +1,122 @@ +package node + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/configutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" +) + +type InstallAction struct { + actionutils.ParentAction +} + +func (this *InstallAction) Init() { + this.Nav("", "node", "install") + this.SecondMenu("nodes") +} + +func (this *InstallAction) RunGet(params struct { + ClusterId int64 + NodeId int64 +}) { + node, err := findHTTPDNSNodeMap(this.Parent(), params.NodeId) + if err != nil { + this.ErrorPage(err) + return + } + cluster, err := findHTTPDNSClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + + this.Data["clusterId"] = params.ClusterId + this.Data["nodeId"] = params.NodeId + this.Data["currentCluster"] = cluster + this.Data["node"] = node + this.Data["installStatus"] = node.GetMap("installStatus") + + apiNodesResp, err := this.RPC().APINodeRPC().FindAllEnabledAPINodes(this.AdminContext(), &pb.FindAllEnabledAPINodesRequest{}) + if err != nil { + this.ErrorPage(err) + return + } + apiEndpoints := make([]string, 0, 8) + for _, apiNode := range apiNodesResp.GetApiNodes() { + if !apiNode.GetIsOn() { + continue + } + apiEndpoints = append(apiEndpoints, apiNode.GetAccessAddrs()...) + } + if len(apiEndpoints) == 0 { + apiEndpoints = []string{"http://127.0.0.1:7788"} + } + this.Data["apiEndpoints"] = "\"" + strings.Join(apiEndpoints, "\", \"") + "\"" + // 从 NodeLogin 中提取 SSH 地址 + this.Data["sshAddr"] = "" + loginMap, _ := node.Get("login").(maps.Map) + if loginMap != nil { + paramsMap, _ := loginMap.Get("params").(maps.Map) + if paramsMap != nil { + host := paramsMap.GetString("host") + if len(host) > 0 { + port := paramsMap.GetInt("port") + if port <= 0 { + port = 22 + } + this.Data["sshAddr"] = fmt.Sprintf("%s:%d", configutils.QuoteIP(host), port) + } + } + } + this.Show() +} + +func (this *InstallAction) RunPost(params struct { + NodeId int64 +}) { + nodeResp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err != nil { + this.ErrorPage(err) + return + } + node := nodeResp.GetNode() + if node == nil { + this.Fail("节点不存在") + return + } + + existingStatus := map[string]interface{}{} + if len(node.GetInstallStatusJSON()) > 0 { + _ = json.Unmarshal(node.GetInstallStatusJSON(), &existingStatus) + } + + existingStatus["isRunning"] = true + existingStatus["isFinished"] = false + existingStatus["isOk"] = false + existingStatus["error"] = "" + existingStatus["errorCode"] = "" + existingStatus["updatedAt"] = time.Now().Unix() + + installStatusJSON, _ := json.Marshal(existingStatus) + _, err = this.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNodeStatus(this.AdminContext(), &pb.UpdateHTTPDNSNodeStatusRequest{ + NodeId: params.NodeId, + IsUp: node.GetIsUp(), + IsInstalled: false, + IsActive: node.GetIsActive(), + StatusJSON: node.GetStatusJSON(), + InstallStatusJSON: installStatusJSON, + }) + if err != nil { + this.ErrorPage(err) + return + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/logs.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/logs.go new file mode 100644 index 0000000..35e4168 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/logs.go @@ -0,0 +1,82 @@ +package node + +import ( + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + timeutil "github.com/iwind/TeaGo/utils/time" + "github.com/iwind/TeaGo/maps" +) + +type LogsAction struct { + actionutils.ParentAction +} + +func (this *LogsAction) Init() { + this.Nav("", "node", "log") + this.SecondMenu("nodes") +} + +func (this *LogsAction) RunGet(params struct { + ClusterId int64 + NodeId int64 + DayFrom string + DayTo string + Level string + Keyword string +}) { + node, err := findHTTPDNSNodeMap(this.Parent(), params.NodeId) + if err != nil { + this.ErrorPage(err) + return + } + cluster, err := findHTTPDNSClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + + this.Data["clusterId"] = params.ClusterId + this.Data["nodeId"] = params.NodeId + this.Data["currentCluster"] = cluster + this.Data["node"] = node + this.Data["dayFrom"] = params.DayFrom + this.Data["dayTo"] = params.DayTo + this.Data["level"] = params.Level + this.Data["keyword"] = params.Keyword + + day := strings.TrimSpace(params.DayFrom) + if len(day) == 0 { + day = strings.TrimSpace(params.DayTo) + } + + resp, err := this.RPC().HTTPDNSRuntimeLogRPC().ListHTTPDNSRuntimeLogs(this.AdminContext(), &pb.ListHTTPDNSRuntimeLogsRequest{ + Day: day, + ClusterId: params.ClusterId, + NodeId: params.NodeId, + Level: strings.TrimSpace(params.Level), + Keyword: strings.TrimSpace(params.Keyword), + Offset: 0, + Size: 100, + }) + if err != nil { + this.ErrorPage(err) + return + } + + logs := make([]maps.Map, 0, len(resp.GetLogs())) + for _, item := range resp.GetLogs() { + logs = append(logs, maps.Map{ + "level": item.GetLevel(), + "tag": item.GetType(), + "description": item.GetDescription(), + "createdAt": item.GetCreatedAt(), + "createdTime": timeutil.FormatTime("Y-m-d H:i:s", item.GetCreatedAt()), + "count": item.GetCount(), + }) + } + this.Data["logs"] = logs + this.Data["page"] = "" + this.Show() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/rpc_helpers.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/rpc_helpers.go new file mode 100644 index 0000000..2694746 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/rpc_helpers.go @@ -0,0 +1,204 @@ +package node + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" +) + +func findHTTPDNSClusterMap(parent *actionutils.ParentAction, clusterID int64) (maps.Map, error) { + resp, err := parent.RPC().HTTPDNSClusterRPC().FindHTTPDNSCluster(parent.AdminContext(), &pb.FindHTTPDNSClusterRequest{ + ClusterId: clusterID, + }) + if err != nil { + return nil, err + } + if resp.GetCluster() == nil { + return maps.Map{ + "id": clusterID, + "name": "", + "installDir": "/root/edge-httpdns", + }, nil + } + + cluster := resp.GetCluster() + installDir := strings.TrimSpace(cluster.GetInstallDir()) + if len(installDir) == 0 { + installDir = "/root/edge-httpdns" + } + return maps.Map{ + "id": cluster.GetId(), + "name": cluster.GetName(), + "installDir": installDir, + }, nil +} + +func findHTTPDNSNodeMap(parent *actionutils.ParentAction, nodeID int64) (maps.Map, error) { + resp, err := parent.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(parent.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: nodeID, + }) + if err != nil { + return nil, err + } + if resp.GetNode() == nil { + return maps.Map{}, nil + } + + node := resp.GetNode() + statusMap := decodeNodeStatus(node.GetStatusJSON()) + installStatusMap := decodeInstallStatus(node.GetInstallStatusJSON()) + + var ipAddresses = []maps.Map{} + if installStatusMap.Has("ipAddresses") { + for _, addr := range installStatusMap.GetSlice("ipAddresses") { + if addrMap, ok := addr.(map[string]interface{}); ok { + m := maps.Map(addrMap) + // 确保必要字段存在,防止前端组件报错 + if !m.Has("name") { + m["name"] = "" + } + if !m.Has("canAccess") { + m["canAccess"] = true + } + if !m.Has("isOn") { + m["isOn"] = true + } + if !m.Has("isUp") { + m["isUp"] = true + } + ipAddresses = append(ipAddresses, m) + } + } + } else { + ip := node.GetName() + if savedIP := strings.TrimSpace(installStatusMap.GetString("ipAddr")); len(savedIP) > 0 { + ip = savedIP + } else if hostIP := strings.TrimSpace(statusMap.GetString("hostIP")); len(hostIP) > 0 { + ip = hostIP + } + ipAddresses = append(ipAddresses, maps.Map{ + "id": node.GetId(), + "name": "Public IP", + "ip": ip, + "canAccess": true, + "isOn": node.GetIsOn(), + "isUp": node.GetIsUp(), + }) + } + + installDir := strings.TrimSpace(node.GetInstallDir()) + if len(installDir) == 0 { + installDir = "/root/edge-httpdns" + } + + clusterMap, err := findHTTPDNSClusterMap(parent, node.GetClusterId()) + if err != nil { + return nil, err + } + + // 构造 node.login 用于 index.html 展示 SSH 信息(从 NodeLogin 读取) + var loginMap maps.Map = nil + if node.GetNodeLogin() != nil { + nodeLogin := node.GetNodeLogin() + sshLoginParams := maps.Map{} + if len(nodeLogin.Params) > 0 { + _ = json.Unmarshal(nodeLogin.Params, &sshLoginParams) + } + + grantId := sshLoginParams.GetInt64("grantId") + var grantMap maps.Map = nil + if grantId > 0 { + grantResp, grantErr := parent.RPC().NodeGrantRPC().FindEnabledNodeGrant(parent.AdminContext(), &pb.FindEnabledNodeGrantRequest{ + NodeGrantId: grantId, + }) + if grantErr == nil && grantResp.GetNodeGrant() != nil { + g := grantResp.GetNodeGrant() + grantMap = maps.Map{ + "id": g.Id, + "name": g.Name, + "methodName": g.Method, + "username": g.Username, + } + } + } + + loginMap = maps.Map{ + "params": maps.Map{ + "host": sshLoginParams.GetString("host"), + "port": sshLoginParams.GetInt("port"), + }, + "grant": grantMap, + } + } + + return maps.Map{ + "id": node.GetId(), + "clusterId": node.GetClusterId(), + "name": node.GetName(), + "isOn": node.GetIsOn(), + "isUp": node.GetIsUp(), + "isInstalled": node.GetIsInstalled(), + "isActive": node.GetIsActive(), + "uniqueId": node.GetUniqueId(), + "secret": node.GetSecret(), + "installDir": installDir, + "status": statusMap, + "installStatus": installStatusMap, + "cluster": clusterMap, + "login": loginMap, + "apiNodeAddrs": []string{}, + "ipAddresses": ipAddresses, + }, nil +} + +func decodeNodeStatus(raw []byte) maps.Map { + status := &nodeconfigs.NodeStatus{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, status) + } + cpuText := fmt.Sprintf("%.2f%%", status.CPUUsage*100) + memText := fmt.Sprintf("%.2f%%", status.MemoryUsage*100) + + return maps.Map{ + "isActive": status.IsActive, + "updatedAt": status.UpdatedAt, + "os": status.OS, + "arch": status.Arch, + "hostname": status.Hostname, + "hostIP": status.HostIP, + "cpuUsage": status.CPUUsage, + "cpuUsageText": cpuText, + "memUsage": status.MemoryUsage, + "memUsageText": memText, + "load1m": status.Load1m, + "load5m": status.Load5m, + "load15m": status.Load15m, + "buildVersion": status.BuildVersion, + "cpuPhysicalCount": status.CPUPhysicalCount, + "cpuLogicalCount": status.CPULogicalCount, + "exePath": status.ExePath, + "apiSuccessPercent": status.APISuccessPercent, + "apiAvgCostSeconds": status.APIAvgCostSeconds, + } +} + +func decodeInstallStatus(raw []byte) maps.Map { + result := maps.Map{ + "isRunning": false, + "isFinished": false, + "isOk": false, + "error": "", + "errorCode": "", + } + if len(raw) == 0 { + return result + } + + _ = json.Unmarshal(raw, &result) + return result +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/start.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/start.go new file mode 100644 index 0000000..55fa5c8 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/start.go @@ -0,0 +1,41 @@ +package node + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" +) + +type StartAction struct { + actionutils.ParentAction +} + +func (this *StartAction) RunPost(params struct { + NodeId int64 +}) { + resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err != nil { + this.ErrorPage(err) + return + } + node := resp.GetNode() + if node == nil { + this.Fail("节点不存在") + return + } + + _, err = this.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNodeStatus(this.AdminContext(), &pb.UpdateHTTPDNSNodeStatusRequest{ + NodeId: params.NodeId, + IsUp: true, + IsInstalled: node.GetIsInstalled(), + IsActive: true, + StatusJSON: node.GetStatusJSON(), + InstallStatusJSON: node.GetInstallStatusJSON(), + }) + if err != nil { + this.ErrorPage(err) + return + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/status.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/status.go new file mode 100644 index 0000000..cc48d5c --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/status.go @@ -0,0 +1,41 @@ +package node + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" +) + +type StatusAction struct { + actionutils.ParentAction +} + +func (this *StatusAction) Init() { + this.Nav("", "node", "status") + this.SecondMenu("nodes") +} + +func (this *StatusAction) RunPost(params struct { + NodeId int64 +}) { + resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err != nil { + this.ErrorPage(err) + return + } + if resp.GetNode() == nil { + this.Fail("节点不存在") + return + } + + nodeMap, err := findHTTPDNSNodeMap(this.Parent(), params.NodeId) + if err != nil { + this.ErrorPage(err) + return + } + + this.Data["installStatus"] = nodeMap.GetMap("installStatus") + this.Data["isInstalled"] = nodeMap.GetBool("isInstalled") + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/stop.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/stop.go new file mode 100644 index 0000000..3865b20 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/stop.go @@ -0,0 +1,41 @@ +package node + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" +) + +type StopAction struct { + actionutils.ParentAction +} + +func (this *StopAction) RunPost(params struct { + NodeId int64 +}) { + resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err != nil { + this.ErrorPage(err) + return + } + node := resp.GetNode() + if node == nil { + this.Fail("节点不存在") + return + } + + _, err = this.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNodeStatus(this.AdminContext(), &pb.UpdateHTTPDNSNodeStatusRequest{ + NodeId: params.NodeId, + IsUp: node.GetIsUp(), + IsInstalled: node.GetIsInstalled(), + IsActive: false, + StatusJSON: node.GetStatusJSON(), + InstallStatusJSON: node.GetInstallStatusJSON(), + }) + if err != nil { + this.ErrorPage(err) + return + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/update.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/update.go new file mode 100644 index 0000000..43ad6b0 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/update.go @@ -0,0 +1,268 @@ +package node + +import ( + "encoding/json" + "net" + "regexp" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/actions" + "github.com/iwind/TeaGo/maps" +) + +type UpdateAction struct { + actionutils.ParentAction +} + +func (this *UpdateAction) Init() { + this.Nav("", "node", "update") + this.SecondMenu("nodes") +} + +func (this *UpdateAction) RunGet(params struct { + ClusterId int64 + NodeId int64 +}) { + node, err := findHTTPDNSNodeMap(this.Parent(), params.NodeId) + if err != nil { + this.ErrorPage(err) + return + } + cluster, err := findHTTPDNSClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + + this.Data["clusterId"] = params.ClusterId + this.Data["nodeId"] = params.NodeId + this.Data["currentCluster"] = cluster + this.Data["clusters"] = []maps.Map{cluster} + this.Data["apiNodeAddrs"] = []string{} + this.Data["node"] = node + + sshHost := "" + sshPort := 22 + ipAddresses := []maps.Map{} + var grantId int64 + var loginId int64 + this.Data["grant"] = nil + + resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err == nil && resp.GetNode() != nil { + // 从 NodeLogin 读取 SSH 信息 + if resp.GetNode().GetNodeLogin() != nil { + nodeLogin := resp.GetNode().GetNodeLogin() + loginId = nodeLogin.Id + if len(nodeLogin.Params) > 0 { + sshLoginParams := maps.Map{} + _ = json.Unmarshal(nodeLogin.Params, &sshLoginParams) + if h := strings.TrimSpace(sshLoginParams.GetString("host")); len(h) > 0 { + sshHost = h + } + if p := sshLoginParams.GetInt("port"); p > 0 { + sshPort = p + } + grantId = sshLoginParams.GetInt64("grantId") + } + } + + // IP 地址仍从 installStatus 读取 + if len(resp.GetNode().GetInstallStatusJSON()) > 0 { + installStatus := maps.Map{} + _ = json.Unmarshal(resp.GetNode().GetInstallStatusJSON(), &installStatus) + + if installStatus.Has("ipAddresses") { + for _, addr := range installStatus.GetSlice("ipAddresses") { + if addrMap, ok := addr.(map[string]interface{}); ok { + m := maps.Map(addrMap) + // 确保必要字段存在,防止前端组件报错 + if !m.Has("name") { + m["name"] = "" + } + if !m.Has("canAccess") { + m["canAccess"] = true + } + if !m.Has("isOn") { + m["isOn"] = true + } + if !m.Has("isUp") { + m["isUp"] = true + } + ipAddresses = append(ipAddresses, m) + } + } + } else if ip := strings.TrimSpace(installStatus.GetString("ipAddr")); len(ip) > 0 { + ipAddresses = append(ipAddresses, maps.Map{ + "ip": ip, + "name": "", + "canAccess": true, + "isOn": true, + "isUp": true, + }) + } + } + } + + this.Data["sshHost"] = sshHost + this.Data["sshPort"] = sshPort + this.Data["loginId"] = loginId + this.Data["ipAddresses"] = ipAddresses + + if grantId > 0 { + grantResp, grantErr := this.RPC().NodeGrantRPC().FindEnabledNodeGrant(this.AdminContext(), &pb.FindEnabledNodeGrantRequest{ + NodeGrantId: grantId, + }) + if grantErr == nil && grantResp.GetNodeGrant() != nil { + g := grantResp.GetNodeGrant() + this.Data["grant"] = maps.Map{ + "id": g.Id, + "name": g.Name, + "methodName": g.Method, + } + } + } + + this.Show() +} + +func (this *UpdateAction) RunPost(params struct { + NodeId int64 + Name string + ClusterId int64 + IsOn bool + LoginId int64 + SshHost string + SshPort int + GrantId int64 + IpAddressesJSON []byte + + Must *actions.Must +}) { + params.Name = strings.TrimSpace(params.Name) + params.Must.Field("name", params.Name).Require("请输入节点名称") + + params.SshHost = strings.TrimSpace(params.SshHost) + hasSSHUpdate := len(params.SshHost) > 0 || params.SshPort > 0 || params.GrantId > 0 + if hasSSHUpdate { + if len(params.SshHost) == 0 { + this.Fail("请输入 SSH 主机地址") + } + if params.SshPort <= 0 || params.SshPort > 65535 { + this.Fail("SSH 端口范围必须在 1-65535 之间") + } + if params.GrantId <= 0 { + this.Fail("请选择 SSH 登录认证") + } + + if regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+$`).MatchString(params.SshHost) && net.ParseIP(params.SshHost) == nil { + this.Fail("SSH 主机地址格式错误") + } + } + + ipAddresses := []maps.Map{} + if len(params.IpAddressesJSON) > 0 { + err := json.Unmarshal(params.IpAddressesJSON, &ipAddresses) + if err != nil { + this.ErrorPage(err) + return + } + } + + for _, addr := range ipAddresses { + ip := addr.GetString("ip") + if net.ParseIP(ip) == nil { + this.Fail("IP地址格式错误: " + ip) + } + } + + resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err != nil { + this.ErrorPage(err) + return + } + if resp.GetNode() == nil { + this.Fail("节点不存在") + return + } + node := resp.GetNode() + + installDir := strings.TrimSpace(node.GetInstallDir()) + if len(installDir) == 0 { + installDir = "/root/edge-httpdns" + } + + _, err = this.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNode(this.AdminContext(), &pb.UpdateHTTPDNSNodeRequest{ + NodeId: params.NodeId, + Name: params.Name, + InstallDir: installDir, + IsOn: params.IsOn, + }) + if err != nil { + this.ErrorPage(err) + return + } + + // SSH 保存到 NodeLogin + if hasSSHUpdate { + login := &pb.NodeLogin{ + Id: params.LoginId, + Name: "SSH", + Type: "ssh", + Params: maps.Map{ + "grantId": params.GrantId, + "host": params.SshHost, + "port": params.SshPort, + }.AsJSON(), + } + + _, err = this.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNodeLogin(this.AdminContext(), &pb.UpdateHTTPDNSNodeLoginRequest{ + NodeId: params.NodeId, + NodeLogin: login, + }) + if err != nil { + this.ErrorPage(err) + return + } + } + + // IP 地址仍保存在 installStatus 中 + if len(ipAddresses) > 0 { + installStatus := maps.Map{ + "isRunning": false, + "isFinished": true, + "isOk": node.GetIsInstalled(), + "error": "", + "errorCode": "", + } + if len(node.GetInstallStatusJSON()) > 0 { + _ = json.Unmarshal(node.GetInstallStatusJSON(), &installStatus) + } + installStatus["ipAddresses"] = ipAddresses + // 清理旧的 ssh 字段 + delete(installStatus, "ssh") + delete(installStatus, "ipAddr") + + installStatusJSON, _ := json.Marshal(installStatus) + _, err = this.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNodeStatus(this.AdminContext(), &pb.UpdateHTTPDNSNodeStatusRequest{ + NodeId: params.NodeId, + IsUp: node.GetIsUp(), + IsInstalled: node.GetIsInstalled(), + IsActive: node.GetIsActive(), + StatusJSON: node.GetStatusJSON(), + InstallStatusJSON: installStatusJSON, + }) + if err != nil { + this.ErrorPage(err) + return + } + } + + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/updateInstallStatus.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/updateInstallStatus.go new file mode 100644 index 0000000..73e94f0 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node/updateInstallStatus.go @@ -0,0 +1,58 @@ +package node + +import ( + "encoding/json" + "time" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" +) + +type UpdateInstallStatusAction struct { + actionutils.ParentAction +} + +func (this *UpdateInstallStatusAction) RunPost(params struct { + NodeId int64 + IsInstalled bool +}) { + resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err != nil { + this.ErrorPage(err) + return + } + node := resp.GetNode() + if node == nil { + this.Fail("节点不存在") + return + } + + installStatus := map[string]interface{}{} + if len(node.GetInstallStatusJSON()) > 0 { + _ = json.Unmarshal(node.GetInstallStatusJSON(), &installStatus) + } + installStatus["isRunning"] = false + installStatus["isFinished"] = params.IsInstalled // 标记为未安装时重置为"未开始"状态 + installStatus["isOk"] = params.IsInstalled + installStatus["error"] = "" + installStatus["errorCode"] = "" + installStatus["updatedAt"] = time.Now().Unix() + + installStatusJSON, _ := json.Marshal(installStatus) + + _, err = this.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNodeStatus(this.AdminContext(), &pb.UpdateHTTPDNSNodeStatusRequest{ + NodeId: params.NodeId, + IsUp: node.GetIsUp(), + IsInstalled: params.IsInstalled, + IsActive: node.GetIsActive(), + StatusJSON: node.GetStatusJSON(), + InstallStatusJSON: installStatusJSON, + }) + if err != nil { + this.ErrorPage(err) + return + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/clusterSettings.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/clusterSettings.go new file mode 100644 index 0000000..29acd26 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/clusterSettings.go @@ -0,0 +1,242 @@ +package clusters + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/sslconfigs" + "github.com/iwind/TeaGo/actions" + "github.com/iwind/TeaGo/maps" + "google.golang.org/grpc/metadata" +) + +type ClusterSettingsAction struct { + actionutils.ParentAction +} + +func (this *ClusterSettingsAction) Init() { + this.Nav("httpdns", "cluster", "settings") +} + +func (this *ClusterSettingsAction) RunGet(params struct { + ClusterId int64 + Section string +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + cluster, err := findClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + httpdnsutils.AddClusterTabbar(this.Parent(), cluster.GetString("name"), params.ClusterId, "setting") + + section := strings.TrimSpace(params.Section) + if len(section) == 0 { + section = "basic" + } + + settings := maps.Map{ + "name": cluster.GetString("name"), + "gatewayDomain": cluster.GetString("gatewayDomain"), + "cacheTtl": cluster.GetInt("defaultTTL"), + "fallbackTimeout": cluster.GetInt("fallbackTimeout"), + "installDir": cluster.GetString("installDir"), + "isOn": cluster.GetBool("isOn"), + "autoRemoteStart": cluster.GetBool("autoRemoteStart"), + "accessLogIsOn": cluster.GetBool("accessLogIsOn"), + "timeZone": cluster.GetString("timeZone"), + } + if settings.GetInt("cacheTtl") <= 0 { + settings["cacheTtl"] = 60 + } + if settings.GetInt("fallbackTimeout") <= 0 { + settings["fallbackTimeout"] = 300 + } + if len(settings.GetString("installDir")) == 0 { + settings["installDir"] = "/root/edge-httpdns" + } + if len(settings.GetString("timeZone")) == 0 { + settings["timeZone"] = "Asia/Shanghai" + } + + listenAddresses := []*serverconfigs.NetworkAddressConfig{ + { + Protocol: serverconfigs.ProtocolTLS, + Host: "", + PortRange: "443", + }, + } + sslPolicy := &sslconfigs.SSLPolicy{ + IsOn: true, + MinVersion: "TLS 1.1", + } + + if rawTLS := strings.TrimSpace(cluster.GetString("tlsPolicyJSON")); len(rawTLS) > 0 { + tlsConfig := maps.Map{} + if err := json.Unmarshal([]byte(rawTLS), &tlsConfig); err == nil { + if listenRaw := tlsConfig.Get("listen"); listenRaw != nil { + if data, err := json.Marshal(listenRaw); err == nil { + _ = json.Unmarshal(data, &listenAddresses) + } + } + if sslRaw := tlsConfig.Get("sslPolicy"); sslRaw != nil { + if data, err := json.Marshal(sslRaw); err == nil { + _ = json.Unmarshal(data, sslPolicy) + } + } + } + } + + this.Data["activeSection"] = section + cid := strconv.FormatInt(params.ClusterId, 10) + this.Data["leftMenuItems"] = []map[string]interface{}{ + {"name": "基础设置", "url": "/httpdns/clusters/cluster/settings?clusterId=" + cid + "§ion=basic", "isActive": section == "basic"}, + {"name": "TLS", "url": "/httpdns/clusters/cluster/settings?clusterId=" + cid + "§ion=tls", "isActive": section == "tls"}, + } + this.Data["cluster"] = cluster + this.Data["settings"] = settings + this.Data["tlsConfig"] = maps.Map{ + "isOn": true, + "listen": listenAddresses, + "sslPolicy": sslPolicy, + } + + this.Data["timeZoneGroups"] = nodeconfigs.FindAllTimeZoneGroups() + this.Data["timeZoneLocations"] = nodeconfigs.FindAllTimeZoneLocations() + timeZoneStr := settings.GetString("timeZone") + if len(timeZoneStr) == 0 { + timeZoneStr = nodeconfigs.DefaultTimeZoneLocation + } + this.Data["timeZoneLocation"] = nodeconfigs.FindTimeZoneLocation(timeZoneStr) + + this.Show() +} + +func (this *ClusterSettingsAction) RunPost(params struct { + ClusterId int64 + Name string + GatewayDomain string + CacheTtl int32 + FallbackTimeout int32 + InstallDir string + IsOn bool + AutoRemoteStart bool + AccessLogIsOn bool + TimeZone string + + Addresses []byte + SslPolicyJSON []byte + + Must *actions.Must + CSRF *actionutils.CSRF +}) { + params.Name = strings.TrimSpace(params.Name) + params.GatewayDomain = strings.TrimSpace(params.GatewayDomain) + params.InstallDir = strings.TrimSpace(params.InstallDir) + + params.Must.Field("clusterId", params.ClusterId).Gt(0, "请选择集群") + params.Must.Field("name", params.Name).Require("请输入集群名称") + params.Must.Field("gatewayDomain", params.GatewayDomain).Require("请输入服务域名") + + cluster, err := findClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + + // 开关项按请求值强制覆盖:未提交/空值都视为 false,支持取消勾选 + autoRemoteStartRaw := strings.ToLower(strings.TrimSpace(this.ParamString("autoRemoteStart"))) + params.AutoRemoteStart = autoRemoteStartRaw == "1" || autoRemoteStartRaw == "true" || autoRemoteStartRaw == "on" || autoRemoteStartRaw == "yes" || autoRemoteStartRaw == "enabled" + + accessLogIsOnRaw := strings.ToLower(strings.TrimSpace(this.ParamString("accessLogIsOn"))) + params.AccessLogIsOn = accessLogIsOnRaw == "1" || accessLogIsOnRaw == "true" || accessLogIsOnRaw == "on" || accessLogIsOnRaw == "yes" || accessLogIsOnRaw == "enabled" + + isOnRaw := strings.ToLower(strings.TrimSpace(this.ParamString("isOn"))) + params.IsOn = isOnRaw == "1" || isOnRaw == "true" || isOnRaw == "on" || isOnRaw == "yes" || isOnRaw == "enabled" + + // 时区为空时继承当前值,再兜底默认值 + params.TimeZone = strings.TrimSpace(this.ParamString("timeZone")) + if len(params.TimeZone) == 0 { + params.TimeZone = strings.TrimSpace(cluster.GetString("timeZone")) + } + if len(params.TimeZone) == 0 { + params.TimeZone = "Asia/Shanghai" + } + + if params.CacheTtl <= 0 { + params.CacheTtl = 60 + } + if params.FallbackTimeout <= 0 { + params.FallbackTimeout = 300 + } + if len(params.InstallDir) == 0 { + params.InstallDir = "/root/edge-httpdns" + } + + tlsConfig := maps.Map{} + if rawTLS := strings.TrimSpace(cluster.GetString("tlsPolicyJSON")); len(rawTLS) > 0 { + _ = json.Unmarshal([]byte(rawTLS), &tlsConfig) + } + if len(params.Addresses) > 0 { + var addresses []*serverconfigs.NetworkAddressConfig + if err := json.Unmarshal(params.Addresses, &addresses); err != nil { + this.Fail("监听端口配置格式不正确") + return + } + tlsConfig["listen"] = addresses + } + if len(params.SslPolicyJSON) > 0 { + sslPolicy := &sslconfigs.SSLPolicy{} + if err := json.Unmarshal(params.SslPolicyJSON, sslPolicy); err != nil { + this.Fail("TLS 配置格式不正确") + return + } + tlsConfig["sslPolicy"] = sslPolicy + } + + var tlsPolicyJSON []byte + if len(tlsConfig) > 0 { + tlsPolicyJSON, err = json.Marshal(tlsConfig) + if err != nil { + this.ErrorPage(err) + return + } + } + + updateReq := &pb.UpdateHTTPDNSClusterRequest{ + ClusterId: params.ClusterId, + Name: params.Name, + ServiceDomain: params.GatewayDomain, + DefaultTTL: params.CacheTtl, + FallbackTimeoutMs: params.FallbackTimeout, + InstallDir: params.InstallDir, + TlsPolicyJSON: tlsPolicyJSON, + IsOn: params.IsOn, + IsDefault: false, + AutoRemoteStart: params.AutoRemoteStart, + AccessLogIsOn: params.AccessLogIsOn, + TimeZone: params.TimeZone, + } + updateCtx := metadata.AppendToOutgoingContext( + this.AdminContext(), + "x-httpdns-auto-remote-start", fmt.Sprintf("%t", updateReq.GetAutoRemoteStart()), + "x-httpdns-access-log-is-on", fmt.Sprintf("%t", updateReq.GetAccessLogIsOn()), + "x-httpdns-time-zone", updateReq.GetTimeZone(), + ) + + _, err = this.RPC().HTTPDNSClusterRPC().UpdateHTTPDNSCluster(updateCtx, updateReq) + if err != nil { + this.ErrorPage(err) + return + } + + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/create.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/create.go new file mode 100644 index 0000000..23bcd22 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/create.go @@ -0,0 +1,78 @@ +package clusters + +import ( + "strconv" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/actions" +) + +type CreateAction struct { + actionutils.ParentAction +} + +func (this *CreateAction) Init() { + this.Nav("httpdns", "cluster", "") +} + +func (this *CreateAction) RunGet(params struct{}) { + httpdnsutils.AddLeftMenu(this.Parent()) + this.Show() +} + +func (this *CreateAction) RunPost(params struct { + Name string + GatewayDomain string + CacheTtl int32 + FallbackTimeout int32 + InstallDir string + IsOn bool + + Must *actions.Must +}) { + params.Name = strings.TrimSpace(params.Name) + params.GatewayDomain = strings.TrimSpace(params.GatewayDomain) + params.InstallDir = strings.TrimSpace(params.InstallDir) + if len(params.InstallDir) == 0 { + params.InstallDir = "/root/edge-httpdns" + } + if params.CacheTtl <= 0 { + params.CacheTtl = 60 + } + if params.FallbackTimeout <= 0 { + params.FallbackTimeout = 300 + } + + params.Must.Field("name", params.Name).Require("请输入集群名称") + params.Must.Field("gatewayDomain", params.GatewayDomain).Require("请输入服务域名") + + resp, err := this.RPC().HTTPDNSClusterRPC().CreateHTTPDNSCluster(this.AdminContext(), &pb.CreateHTTPDNSClusterRequest{ + Name: params.Name, + ServiceDomain: params.GatewayDomain, + DefaultTTL: params.CacheTtl, + FallbackTimeoutMs: params.FallbackTimeout, + InstallDir: params.InstallDir, + IsOn: params.IsOn, + IsDefault: false, + AutoRemoteStart: true, + AccessLogIsOn: true, + TimeZone: "Asia/Shanghai", + }) + if err != nil { + this.ErrorPage(err) + return + } + + this.Data["clusterId"] = resp.GetClusterId() + + // fallback: if frontend JS doesn't intercept form submit, redirect instead of showing raw JSON + if len(this.Request.Header.Get("X-Requested-With")) == 0 { + this.RedirectURL("/httpdns/clusters/cluster?clusterId=" + strconv.FormatInt(resp.GetClusterId(), 10)) + return + } + + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/createNode.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/createNode.go new file mode 100644 index 0000000..9b57ca4 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/createNode.go @@ -0,0 +1,63 @@ +package clusters + +import ( + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/iwind/TeaGo/actions" +) + +type CreateNodeAction struct { + actionutils.ParentAction +} + +func (this *CreateNodeAction) Init() { + this.Nav("httpdns", "cluster", "createNode") +} + +func (this *CreateNodeAction) RunGet(params struct { + ClusterId int64 +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + cluster, err := findClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + + httpdnsutils.AddClusterTabbar(this.Parent(), cluster.GetString("name"), params.ClusterId, "node") + this.Data["clusterId"] = params.ClusterId + this.Data["cluster"] = cluster + this.Show() +} + +func (this *CreateNodeAction) RunPost(params struct { + ClusterId int64 + Name string + InstallDir string + + Must *actions.Must +}) { + params.Name = strings.TrimSpace(params.Name) + params.InstallDir = strings.TrimSpace(params.InstallDir) + params.Must.Field("clusterId", params.ClusterId).Gt(0, "请选择集群") + params.Must.Field("name", params.Name).Require("请输入节点名称") + + if len(params.InstallDir) == 0 { + cluster, err := findClusterMap(this.Parent(), params.ClusterId) + if err == nil { + params.InstallDir = strings.TrimSpace(cluster.GetString("installDir")) + } + if len(params.InstallDir) == 0 { + params.InstallDir = "/root/edge-httpdns" + } + } + + if err := createNode(this.Parent(), params.ClusterId, params.Name, params.InstallDir); err != nil { + this.ErrorPage(err) + return + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/delete.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/delete.go new file mode 100644 index 0000000..4b624b9 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/delete.go @@ -0,0 +1,44 @@ +package clusters + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" +) + +type DeleteAction struct { + actionutils.ParentAction +} + +func (this *DeleteAction) Init() { + this.Nav("httpdns", "cluster", "delete") +} + +func (this *DeleteAction) RunGet(params struct { + ClusterId int64 +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + cluster, err := findClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + + httpdnsutils.AddClusterTabbar(this.Parent(), cluster.GetString("name"), params.ClusterId, "delete") + this.Data["cluster"] = cluster + this.Show() +} + +func (this *DeleteAction) RunPost(params struct { + ClusterId int64 +}) { + _, err := this.RPC().HTTPDNSClusterRPC().DeleteHTTPDNSCluster(this.AdminContext(), &pb.DeleteHTTPDNSClusterRequest{ + ClusterId: params.ClusterId, + }) + if err != nil { + this.ErrorPage(err) + return + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/deleteNode.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/deleteNode.go new file mode 100644 index 0000000..eecc635 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/deleteNode.go @@ -0,0 +1,18 @@ +package clusters + +import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + +type DeleteNodeAction struct { + actionutils.ParentAction +} + +func (this *DeleteNodeAction) RunPost(params struct { + ClusterId int64 + NodeId int64 +}) { + if err := deleteNode(this.Parent(), params.NodeId); err != nil { + this.ErrorPage(err) + return + } + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/index.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/index.go new file mode 100644 index 0000000..9f2e27b --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/index.go @@ -0,0 +1,39 @@ +package clusters + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" +) + +type IndexAction struct { + actionutils.ParentAction +} + +func (this *IndexAction) Init() { + this.Nav("httpdns", "cluster", "") +} + +func (this *IndexAction) RunGet(params struct { + Keyword string +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + this.Data["keyword"] = params.Keyword + + clusters, err := listClusterMaps(this.Parent(), params.Keyword) + if err != nil { + this.ErrorPage(err) + return + } + this.Data["clusters"] = clusters + + hasErrorLogs := false + for _, cluster := range clusters { + if cluster.GetInt("countAllNodes") > cluster.GetInt("countActiveNodes") { + hasErrorLogs = true + break + } + } + this.Data["hasErrorLogs"] = hasErrorLogs + this.Data["page"] = "" + this.Show() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/init.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/init.go new file mode 100644 index 0000000..3c61885 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/init.go @@ -0,0 +1,43 @@ +package clusters + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/configloaders" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node" + "github.com/TeaOSLab/EdgeAdmin/internal/web/helpers" + "github.com/iwind/TeaGo" +) + +func init() { + TeaGo.BeforeStart(func(server *TeaGo.Server) { + server. + Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)). + Data("teaMenu", "httpdns"). + Data("teaSubMenu", "cluster"). + Prefix("/httpdns/clusters"). + Get("", new(IndexAction)). + GetPost("/create", new(CreateAction)). + Get("/cluster", new(ClusterAction)). + GetPost("/cluster/settings", new(ClusterSettingsAction)). + GetPost("/delete", new(DeleteAction)). + // Node level + GetPost("/createNode", new(CreateNodeAction)). + Post("/deleteNode", new(DeleteNodeAction)). + GetPost("/cluster/upgradeRemote", new(UpgradeRemoteAction)). + GetPost("/upgradeRemote", new(UpgradeRemoteAction)). + Post("/upgradeStatus", new(UpgradeStatusAction)). + GetPost("/updateNodeSSH", new(UpdateNodeSSHAction)). + Post("/checkPorts", new(CheckPortsAction)). + + // Node internal pages + Prefix("/httpdns/clusters/cluster/node"). + Get("", new(node.IndexAction)). + Get("/logs", new(node.LogsAction)). + GetPost("/update", new(node.UpdateAction)). + GetPost("/install", new(node.InstallAction)). + Post("/status", new(node.StatusAction)). + Post("/updateInstallStatus", new(node.UpdateInstallStatusAction)). + Post("/start", new(node.StartAction)). + Post("/stop", new(node.StopAction)). + EndAll() + }) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/rpc_helpers.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/rpc_helpers.go new file mode 100644 index 0000000..8a6013d --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/rpc_helpers.go @@ -0,0 +1,347 @@ +package clusters + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func listClusterMaps(parent *actionutils.ParentAction, keyword string) ([]maps.Map, error) { + resp, err := parent.RPC().HTTPDNSClusterRPC().ListHTTPDNSClusters(parent.AdminContext(), &pb.ListHTTPDNSClustersRequest{ + Offset: 0, + Size: 10_000, + Keyword: strings.TrimSpace(keyword), + }) + if err != nil { + return nil, err + } + + result := make([]maps.Map, 0, len(resp.GetClusters())) + for _, cluster := range resp.GetClusters() { + nodesResp, err := parent.RPC().HTTPDNSNodeRPC().ListHTTPDNSNodes(parent.AdminContext(), &pb.ListHTTPDNSNodesRequest{ + ClusterId: cluster.GetId(), + }) + if err != nil { + return nil, err + } + countAllNodes := len(nodesResp.GetNodes()) + countActiveNodes := 0 + for _, node := range nodesResp.GetNodes() { + if node.GetIsOn() && node.GetIsUp() && node.GetIsActive() { + countActiveNodes++ + } + } + + countUpgradeNodes, err := countUpgradeHTTPDNSNodes(parent, cluster.GetId()) + if err != nil { + return nil, err + } + + port := "443" + if rawTLS := cluster.GetTlsPolicyJSON(); len(rawTLS) > 0 { + tlsConfig := maps.Map{} + if err := json.Unmarshal(rawTLS, &tlsConfig); err == nil { + if listenRaw := tlsConfig.Get("listen"); listenRaw != nil { + var listenAddresses []maps.Map + if data, err := json.Marshal(listenRaw); err == nil { + if err := json.Unmarshal(data, &listenAddresses); err == nil { + if len(listenAddresses) > 0 && len(listenAddresses[0].GetString("portRange")) > 0 { + port = listenAddresses[0].GetString("portRange") + } + } + } + } + } + } + apiAddress := "https://" + cluster.GetServiceDomain() + ":" + port + + result = append(result, maps.Map{ + "id": cluster.GetId(), + "name": cluster.GetName(), + "gatewayDomain": cluster.GetServiceDomain(), + "apiAddress": apiAddress, + "defaultTTL": cluster.GetDefaultTTL(), + "fallbackTimeout": cluster.GetFallbackTimeoutMs(), + "installDir": cluster.GetInstallDir(), + "timeZone": cluster.GetTimeZone(), + "isOn": cluster.GetIsOn(), + "isDefault": cluster.GetIsDefault(), + "autoRemoteStart": cluster.GetAutoRemoteStart(), + "accessLogIsOn": cluster.GetAccessLogIsOn(), + "tlsPolicyJSON": cluster.GetTlsPolicyJSON(), + "countAllNodes": countAllNodes, + "countActiveNodes": countActiveNodes, + "countUpgradeNodes": countUpgradeNodes, + }) + } + return result, nil +} + +func countUpgradeHTTPDNSNodes(parent *actionutils.ParentAction, clusterID int64) (int64, error) { + countResp, err := parent.RPC().HTTPDNSNodeRPC().CountAllUpgradeHTTPDNSNodesWithClusterId(parent.AdminContext(), &pb.CountAllUpgradeHTTPDNSNodesWithClusterIdRequest{ + ClusterId: clusterID, + }) + if err == nil { + return countResp.GetCount(), nil + } + + grpcStatus, ok := status.FromError(err) + if !ok || grpcStatus.Code() != codes.Unimplemented { + return 0, err + } + + // Compatibility fallback: old edge-api may not implement countAllUpgradeHTTPDNSNodesWithClusterId yet. + listResp, listErr := parent.RPC().HTTPDNSNodeRPC().FindAllUpgradeHTTPDNSNodesWithClusterId(parent.AdminContext(), &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdRequest{ + ClusterId: clusterID, + }) + if listErr == nil { + return int64(len(listResp.GetNodes())), nil + } + + listStatus, ok := status.FromError(listErr) + if ok && listStatus.Code() == codes.Unimplemented { + // Compatibility fallback: both methods missing on old edge-api, don't block page rendering. + return 0, nil + } + + return 0, listErr +} + +func findClusterMap(parent *actionutils.ParentAction, clusterID int64) (maps.Map, error) { + if clusterID > 0 { + var headerMD metadata.MD + resp, err := parent.RPC().HTTPDNSClusterRPC().FindHTTPDNSCluster(parent.AdminContext(), &pb.FindHTTPDNSClusterRequest{ + ClusterId: clusterID, + }, grpc.Header(&headerMD)) + if err != nil { + return nil, err + } + if resp.GetCluster() != nil { + cluster := resp.GetCluster() + autoRemoteStart := cluster.GetAutoRemoteStart() + accessLogIsOn := cluster.GetAccessLogIsOn() + timeZone := cluster.GetTimeZone() + + // Compatibility fallback: + // Some deployed admin binaries may decode newly-added protobuf fields incorrectly. + // Read values from grpc response headers as a source of truth. + if values := headerMD.Get("x-httpdns-auto-remote-start"); len(values) > 0 { + autoRemoteStart = parseBoolLike(values[0], autoRemoteStart) + } + if values := headerMD.Get("x-httpdns-access-log-is-on"); len(values) > 0 { + accessLogIsOn = parseBoolLike(values[0], accessLogIsOn) + } + if values := headerMD.Get("x-httpdns-time-zone"); len(values) > 0 { + if tz := strings.TrimSpace(values[0]); len(tz) > 0 { + timeZone = tz + } + } + + return maps.Map{ + "id": cluster.GetId(), + "name": cluster.GetName(), + "gatewayDomain": cluster.GetServiceDomain(), + "defaultTTL": cluster.GetDefaultTTL(), + "fallbackTimeout": cluster.GetFallbackTimeoutMs(), + "installDir": cluster.GetInstallDir(), + "isOn": cluster.GetIsOn(), + "isDefault": cluster.GetIsDefault(), + "tlsPolicyJSON": cluster.GetTlsPolicyJSON(), + "autoRemoteStart": autoRemoteStart, + "accessLogIsOn": accessLogIsOn, + "timeZone": timeZone, + }, nil + } + } + + clusters, err := listClusterMaps(parent, "") + if err != nil { + return nil, err + } + if len(clusters) == 0 { + return maps.Map{ + "id": int64(0), + "name": "", + "gatewayDomain": "", + "defaultTTL": 60, + "fallbackTimeout": 300, + "installDir": "/root/edge-httpdns", + "timeZone": "Asia/Shanghai", + }, nil + } + return clusters[0], nil +} + +func parseBoolLike(raw string, defaultValue bool) bool { + s := strings.ToLower(strings.TrimSpace(raw)) + switch s { + case "1", "true", "on", "yes", "enabled": + return true + case "0", "false", "off", "no", "disabled": + return false + default: + return defaultValue + } +} + +func listNodeMaps(parent *actionutils.ParentAction, clusterID int64) ([]maps.Map, error) { + resp, err := parent.RPC().HTTPDNSNodeRPC().ListHTTPDNSNodes(parent.AdminContext(), &pb.ListHTTPDNSNodesRequest{ + ClusterId: clusterID, + }) + if err != nil { + return nil, err + } + + result := make([]maps.Map, 0, len(resp.GetNodes())) + for _, node := range resp.GetNodes() { + statusMap := decodeNodeStatus(node.GetStatusJSON()) + installStatusMap := decodeInstallStatus(node.GetInstallStatusJSON()) + ip := node.GetName() + if parsed := strings.TrimSpace(statusMap.GetString("hostIP")); len(parsed) > 0 { + ip = parsed + } + nodeMap := maps.Map{ + "id": node.GetId(), + "clusterId": node.GetClusterId(), + "name": node.GetName(), + "isOn": node.GetIsOn(), + "isUp": node.GetIsUp(), + "isInstalled": node.GetIsInstalled(), + "isActive": node.GetIsActive(), + "installDir": node.GetInstallDir(), + "uniqueId": node.GetUniqueId(), + "secret": node.GetSecret(), + "status": statusMap, + "installStatus": installStatusMap, + "region": nil, + "login": nil, + "apiNodeAddrs": []string{}, + "cluster": maps.Map{ + "id": node.GetClusterId(), + "installDir": node.GetInstallDir(), + }, + "ipAddresses": []maps.Map{ + { + "id": node.GetId(), + "name": "Public IP", + "ip": ip, + "canAccess": true, + "isOn": node.GetIsOn(), + "isUp": node.GetIsUp(), + }, + }, + } + result = append(result, nodeMap) + } + return result, nil +} + +func findNodeMap(parent *actionutils.ParentAction, nodeID int64) (maps.Map, error) { + resp, err := parent.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(parent.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: nodeID, + }) + if err != nil { + return nil, err + } + if resp.GetNode() == nil { + return maps.Map{}, nil + } + nodes, err := listNodeMaps(parent, resp.GetNode().GetClusterId()) + if err != nil { + return nil, err + } + for _, node := range nodes { + if node.GetInt64("id") == nodeID { + return node, nil + } + } + return maps.Map{ + "id": resp.GetNode().GetId(), + "name": resp.GetNode().GetName(), + }, nil +} + +func createNode(parent *actionutils.ParentAction, clusterID int64, name string, installDir string) error { + _, err := parent.RPC().HTTPDNSNodeRPC().CreateHTTPDNSNode(parent.AdminContext(), &pb.CreateHTTPDNSNodeRequest{ + ClusterId: clusterID, + Name: strings.TrimSpace(name), + InstallDir: strings.TrimSpace(installDir), + IsOn: true, + }) + return err +} + +func updateNode(parent *actionutils.ParentAction, nodeID int64, name string, installDir string, isOn bool) error { + _, err := parent.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNode(parent.AdminContext(), &pb.UpdateHTTPDNSNodeRequest{ + NodeId: nodeID, + Name: strings.TrimSpace(name), + InstallDir: strings.TrimSpace(installDir), + IsOn: isOn, + }) + return err +} + +func deleteNode(parent *actionutils.ParentAction, nodeID int64) error { + _, err := parent.RPC().HTTPDNSNodeRPC().DeleteHTTPDNSNode(parent.AdminContext(), &pb.DeleteHTTPDNSNodeRequest{ + NodeId: nodeID, + }) + return err +} + +func decodeNodeStatus(raw []byte) maps.Map { + status := &nodeconfigs.NodeStatus{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, status) + } + cpuText := fmt.Sprintf("%.2f%%", status.CPUUsage*100) + memText := fmt.Sprintf("%.2f%%", status.MemoryUsage*100) + return maps.Map{ + "isActive": status.IsActive, + "updatedAt": status.UpdatedAt, + "hostname": status.Hostname, + "hostIP": status.HostIP, + "cpuUsage": status.CPUUsage, + "cpuUsageText": cpuText, + "memUsage": status.MemoryUsage, + "memUsageText": memText, + "load1m": status.Load1m, + "load5m": status.Load5m, + "load15m": status.Load15m, + "buildVersion": status.BuildVersion, + "cpuPhysicalCount": status.CPUPhysicalCount, + "cpuLogicalCount": status.CPULogicalCount, + "exePath": status.ExePath, + } +} + +func decodeInstallStatus(raw []byte) maps.Map { + if len(raw) == 0 { + return maps.Map{ + "isRunning": false, + "isFinished": true, + "isOk": true, + "error": "", + "errorCode": "", + } + } + result := maps.Map{} + if err := json.Unmarshal(raw, &result); err != nil { + return maps.Map{ + "isRunning": false, + "isFinished": true, + "isOk": true, + "error": "", + "errorCode": "", + } + } + return result +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/updateNodeSSH.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/updateNodeSSH.go new file mode 100644 index 0000000..778b573 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/updateNodeSSH.go @@ -0,0 +1,128 @@ +package clusters + +import ( + "encoding/json" + "net" + "regexp" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/actions" + "github.com/iwind/TeaGo/maps" +) + +type UpdateNodeSSHAction struct { + actionutils.ParentAction +} + +func (this *UpdateNodeSSHAction) RunGet(params struct { + NodeId int64 +}) { + resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err != nil { + this.ErrorPage(err) + return + } + + clusterId := int64(0) + nodeName := "" + if resp.GetNode() != nil { + clusterId = resp.GetNode().GetClusterId() + nodeName = resp.GetNode().GetName() + } + + this.Data["nodeId"] = params.NodeId + this.Data["clusterId"] = clusterId + this.Data["node"] = maps.Map{ + "id": params.NodeId, + "name": nodeName, + } + loginParams := maps.Map{ + "host": "", + "port": 22, + "grantId": 0, + } + this.Data["loginId"] = 0 + + // 从 NodeLogin 读取 SSH 信息 + if resp.GetNode() != nil && resp.GetNode().GetNodeLogin() != nil { + nodeLogin := resp.GetNode().GetNodeLogin() + this.Data["loginId"] = nodeLogin.Id + if len(nodeLogin.Params) > 0 { + _ = json.Unmarshal(nodeLogin.Params, &loginParams) + } + } + + this.Data["params"] = loginParams + + // 认证信息 + grantId := loginParams.GetInt64("grantId") + var grantMap maps.Map = nil + if grantId > 0 { + grantResp, grantErr := this.RPC().NodeGrantRPC().FindEnabledNodeGrant(this.AdminContext(), &pb.FindEnabledNodeGrantRequest{ + NodeGrantId: grantId, + }) + if grantErr == nil && grantResp.GetNodeGrant() != nil { + g := grantResp.GetNodeGrant() + grantMap = maps.Map{ + "id": g.Id, + "name": g.Name, + "method": g.Method, + "methodName": g.Method, + } + } + } + this.Data["grant"] = grantMap + + this.Show() +} + +func (this *UpdateNodeSSHAction) RunPost(params struct { + NodeId int64 + LoginId int64 + SshHost string + SshPort int + GrantId int64 + + Must *actions.Must +}) { + params.SshHost = strings.TrimSpace(params.SshHost) + params.Must. + Field("sshHost", params.SshHost). + Require("请输入 SSH 主机地址"). + Field("sshPort", params.SshPort). + Gt(0, "SSH 端口必须大于 0"). + Lt(65535, "SSH 端口必须小于 65535") + if params.GrantId <= 0 { + this.Fail("请选择节点登录认证信息") + } + + if regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+$`).MatchString(params.SshHost) && net.ParseIP(params.SshHost) == nil { + this.Fail("SSH 主机地址 IP 格式错误") + } + + login := &pb.NodeLogin{ + Id: params.LoginId, + Name: "SSH", + Type: "ssh", + Params: maps.Map{ + "grantId": params.GrantId, + "host": params.SshHost, + "port": params.SshPort, + }.AsJSON(), + } + + _, err := this.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNodeLogin(this.AdminContext(), &pb.UpdateHTTPDNSNodeLoginRequest{ + NodeId: params.NodeId, + NodeLogin: login, + }) + if err != nil { + this.Fail("保存SSH登录信息失败: " + err.Error()) + return + } + + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/upgradeRemote.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/upgradeRemote.go new file mode 100644 index 0000000..846151f --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/upgradeRemote.go @@ -0,0 +1,108 @@ +package clusters + +import ( + "encoding/json" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/actions" + "github.com/iwind/TeaGo/maps" +) + +type UpgradeRemoteAction struct { + actionutils.ParentAction +} + +func (this *UpgradeRemoteAction) Init() { + this.Nav("httpdns", "cluster", "index") +} + +func (this *UpgradeRemoteAction) RunGet(params struct { + NodeId int64 + ClusterId int64 +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + cluster, err := findClusterMap(this.Parent(), params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + httpdnsutils.AddClusterTabbar(this.Parent(), cluster.GetString("name"), params.ClusterId, "node") + + this.Data["clusterId"] = params.ClusterId + this.Data["cluster"] = cluster + + resp, err := this.RPC().HTTPDNSNodeRPC().FindAllUpgradeHTTPDNSNodesWithClusterId(this.AdminContext(), &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdRequest{ + ClusterId: params.ClusterId, + }) + if err != nil { + this.ErrorPage(err) + return + } + + nodes := make([]maps.Map, 0, len(resp.GetNodes())) + for _, upgradeNode := range resp.GetNodes() { + node := upgradeNode.Node + if node == nil { + continue + } + + loginParams := maps.Map{} + if node.GetNodeLogin() != nil && len(node.GetNodeLogin().GetParams()) > 0 { + _ = json.Unmarshal(node.GetNodeLogin().GetParams(), &loginParams) + } + + status := decodeNodeStatus(node.GetStatusJSON()) + accessIP := strings.TrimSpace(status.GetString("hostIP")) + if len(accessIP) == 0 { + accessIP = strings.TrimSpace(node.GetName()) + } + + nodes = append(nodes, maps.Map{ + "id": node.GetId(), + "name": node.GetName(), + "accessIP": accessIP, + "oldVersion": upgradeNode.OldVersion, + "newVersion": upgradeNode.NewVersion, + "login": node.GetNodeLogin(), + "loginParams": loginParams, + "installStatus": decodeUpgradeInstallStatus(node.GetInstallStatusJSON()), + }) + } + this.Data["nodes"] = nodes + this.Show() +} + +func (this *UpgradeRemoteAction) RunPost(params struct { + NodeId int64 + + Must *actions.Must +}) { + _, err := this.RPC().HTTPDNSNodeRPC().UpgradeHTTPDNSNode(this.AdminContext(), &pb.UpgradeHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err != nil { + this.ErrorPage(err) + return + } + this.Success() +} + +func decodeUpgradeInstallStatus(raw []byte) maps.Map { + result := maps.Map{ + "isRunning": false, + "isFinished": false, + "isOk": false, + "error": "", + "errorCode": "", + } + if len(raw) == 0 { + return result + } + + _ = json.Unmarshal(raw, &result) + return result +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/clusters/upgradeStatus.go b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/upgradeStatus.go new file mode 100644 index 0000000..5635d2e --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/clusters/upgradeStatus.go @@ -0,0 +1,48 @@ +package clusters + +import ( + "encoding/json" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" +) + +type UpgradeStatusAction struct { + actionutils.ParentAction +} + +func (this *UpgradeStatusAction) RunPost(params struct { + NodeId int64 +}) { + resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{ + NodeId: params.NodeId, + }) + if err != nil { + this.ErrorPage(err) + return + } + if resp.GetNode() == nil { + this.Data["status"] = nil + this.Success() + return + } + + this.Data["status"] = decodeUpgradeInstallStatusMap(resp.GetNode().GetInstallStatusJSON()) + this.Success() +} + +func decodeUpgradeInstallStatusMap(raw []byte) map[string]interface{} { + result := map[string]interface{}{ + "isRunning": false, + "isFinished": false, + "isOk": false, + "error": "", + "errorCode": "", + } + if len(raw) == 0 { + return result + } + + _ = json.Unmarshal(raw, &result) + return result +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/ech/audit.go b/EdgeAdmin/internal/web/actions/default/httpdns/ech/audit.go new file mode 100644 index 0000000..a9d8c2b --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/ech/audit.go @@ -0,0 +1,15 @@ +package ech + +import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + +type AuditAction struct { + actionutils.ParentAction +} + +func (this *AuditAction) Init() { + this.Nav("httpdns", "ech", "") +} + +func (this *AuditAction) RunGet(params struct{}) { + this.RedirectURL("/httpdns/apps") +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/ech/index.go b/EdgeAdmin/internal/web/actions/default/httpdns/ech/index.go new file mode 100644 index 0000000..76b9832 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/ech/index.go @@ -0,0 +1,15 @@ +package ech + +import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + +type IndexAction struct { + actionutils.ParentAction +} + +func (this *IndexAction) Init() { + this.Nav("httpdns", "ech", "") +} + +func (this *IndexAction) RunGet(params struct{}) { + this.RedirectURL("/httpdns/apps") +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/ech/init.go b/EdgeAdmin/internal/web/actions/default/httpdns/ech/init.go new file mode 100644 index 0000000..b4564e9 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/ech/init.go @@ -0,0 +1,21 @@ +package ech + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/configloaders" + "github.com/TeaOSLab/EdgeAdmin/internal/web/helpers" + "github.com/iwind/TeaGo" +) + +func init() { + TeaGo.BeforeStart(func(server *TeaGo.Server) { + server. + Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)). + Data("teaMenu", "httpdns"). + Data("teaSubMenu", "ech"). + Prefix("/httpdns/ech"). + Get("", new(IndexAction)). + Get("/audit", new(AuditAction)). + GetPost("/rollbackMfaPopup", new(RollbackMfaPopupAction)). + EndAll() + }) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/ech/rollbackMfaPopup.go b/EdgeAdmin/internal/web/actions/default/httpdns/ech/rollbackMfaPopup.go new file mode 100644 index 0000000..18c35c5 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/ech/rollbackMfaPopup.go @@ -0,0 +1,32 @@ +package ech + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/iwind/TeaGo/actions" +) + +type RollbackMfaPopupAction struct { + actionutils.ParentAction +} + +func (this *RollbackMfaPopupAction) Init() { + this.Nav("", "", "") +} + +func (this *RollbackMfaPopupAction) RunGet(params struct { + LogId int64 +}) { + this.RedirectURL("/httpdns/apps") +} + +func (this *RollbackMfaPopupAction) RunPost(params struct { + LogId int64 + Reason string + OtpCode1 string + OtpCode2 string + + Must *actions.Must + CSRF *actionutils.CSRF +}) { + this.Fail("feature removed") +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils/helper.go b/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils/helper.go new file mode 100644 index 0000000..dc0b27d --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils/helper.go @@ -0,0 +1,67 @@ +package httpdnsutils + +import ( + "strconv" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/iwind/TeaGo/maps" +) + +func AddLeftMenu(action *actionutils.ParentAction) { + tab := action.Data.GetString("mainTab") + action.Data["teaMenu"] = "httpdns" + action.Data["teaSubMenu"] = tab + action.Data["leftMenuItems"] = []maps.Map{ + { + "name": "\u96c6\u7fa4\u7ba1\u7406", + "url": "/httpdns/clusters", + "isActive": tab == "cluster", + }, + { + "name": "\u5e94\u7528\u7ba1\u7406", + "url": "/httpdns/apps", + "isActive": tab == "app", + }, + { + "name": "\u8bbf\u95ee\u65e5\u5fd7", + "url": "/httpdns/resolveLogs", + "isActive": tab == "resolveLogs", + }, + { + "name": "\u8fd0\u884c\u65e5\u5fd7", + "url": "/httpdns/runtimeLogs", + "isActive": tab == "runtimeLogs", + }, + { + "name": "\u89e3\u6790\u6d4b\u8bd5", + "url": "/httpdns/sandbox", + "isActive": tab == "sandbox", + }, + } +} + +// AddClusterTabbar builds the top tabbar on cluster pages. +func AddClusterTabbar(action *actionutils.ParentAction, clusterName string, clusterId int64, selectedTab string) { + cid := strconv.FormatInt(clusterId, 10) + tabbar := actionutils.NewTabbar() + tabbar.Add("", "", "/httpdns/clusters", "arrow left", false) + titleItem := tabbar.Add(clusterName, "", "/httpdns/clusters/cluster?clusterId="+cid, "angle right", true) + titleItem.IsTitle = true + tabbar.Add("\u8282\u70b9\u5217\u8868", "", "/httpdns/clusters/cluster?clusterId="+cid, "server", selectedTab == "node") + tabbar.Add("\u96c6\u7fa4\u8bbe\u7f6e", "", "/httpdns/clusters/cluster/settings?clusterId="+cid, "setting", selectedTab == "setting") + tabbar.Add("\u5220\u9664\u96c6\u7fa4", "", "/httpdns/clusters/delete?clusterId="+cid, "trash", selectedTab == "delete") + actionutils.SetTabbar(action, tabbar) +} + +// AddAppTabbar builds the top tabbar on app pages. +func AddAppTabbar(action *actionutils.ParentAction, appName string, appId int64, selectedTab string) { + aid := strconv.FormatInt(appId, 10) + tabbar := actionutils.NewTabbar() + tabbar.Add("", "", "/httpdns/apps", "arrow left", false) + titleItem := tabbar.Add(appName, "", "/httpdns/apps/domains?appId="+aid, "angle right", true) + titleItem.IsTitle = true + tabbar.Add("\u57df\u540d\u5217\u8868", "", "/httpdns/apps/domains?appId="+aid, "list", selectedTab == "domains") + tabbar.Add("\u5e94\u7528\u8bbe\u7f6e", "", "/httpdns/apps/app/settings?appId="+aid, "setting", selectedTab == "settings") + tabbar.Add("\u5220\u9664\u5e94\u7528", "", "/httpdns/apps/delete?appId="+aid, "trash", selectedTab == "delete") + actionutils.SetTabbar(action, tabbar) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/index.go b/EdgeAdmin/internal/web/actions/default/httpdns/index.go new file mode 100644 index 0000000..df37453 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/index.go @@ -0,0 +1,11 @@ +package httpdns + +import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + +type IndexAction struct { + actionutils.ParentAction +} + +func (this *IndexAction) RunGet(params struct{}) { + this.RedirectURL("/httpdns/clusters") +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/init.go b/EdgeAdmin/internal/web/actions/default/httpdns/init.go new file mode 100644 index 0000000..78dc22b --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/init.go @@ -0,0 +1,20 @@ +package httpdns + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/configloaders" + "github.com/TeaOSLab/EdgeAdmin/internal/web/helpers" + "github.com/iwind/TeaGo" +) + +func init() { + TeaGo.BeforeStart(func(server *TeaGo.Server) { + server. + Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)). + Data("teaMenu", "httpdns"). + Data("teaSubMenu", "cluster"). + Prefix("/httpdns"). + Get("", new(IndexAction)). + GetPost("/addPortPopup", new(AddPortPopupAction)). + EndAll() + }) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/resolveLogs/index.go b/EdgeAdmin/internal/web/actions/default/httpdns/resolveLogs/index.go new file mode 100644 index 0000000..2dbf574 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/resolveLogs/index.go @@ -0,0 +1,129 @@ +package resolveLogs + +import ( + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + timeutil "github.com/iwind/TeaGo/utils/time" +) + +type IndexAction struct { + actionutils.ParentAction +} + +func (this *IndexAction) Init() { + this.Nav("httpdns", "resolveLogs", "") +} + +func (this *IndexAction) RunGet(params struct { + ClusterId int64 + NodeId int64 + AppId string + Domain string + Status string + Keyword string +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + if params.ClusterId > 0 { + this.Data["clusterId"] = params.ClusterId + } else { + this.Data["clusterId"] = "" + } + if params.NodeId > 0 { + this.Data["nodeId"] = params.NodeId + } else { + this.Data["nodeId"] = "" + } + this.Data["appId"] = params.AppId + this.Data["domain"] = params.Domain + this.Data["status"] = params.Status + this.Data["keyword"] = params.Keyword + + clusterResp, err := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if err != nil { + this.ErrorPage(err) + return + } + clusters := make([]map[string]interface{}, 0, len(clusterResp.GetClusters())) + nodes := make([]map[string]interface{}, 0) + for _, cluster := range clusterResp.GetClusters() { + clusters = append(clusters, map[string]interface{}{ + "id": cluster.GetId(), + "name": cluster.GetName(), + }) + nodeResp, err := this.RPC().HTTPDNSNodeRPC().ListHTTPDNSNodes(this.AdminContext(), &pb.ListHTTPDNSNodesRequest{ + ClusterId: cluster.GetId(), + }) + if err != nil { + this.ErrorPage(err) + return + } + for _, node := range nodeResp.GetNodes() { + nodes = append(nodes, map[string]interface{}{ + "id": node.GetId(), + "clusterId": node.GetClusterId(), + "name": node.GetName(), + }) + } + } + this.Data["clusters"] = clusters + this.Data["nodes"] = nodes + + logResp, err := this.RPC().HTTPDNSAccessLogRPC().ListHTTPDNSAccessLogs(this.AdminContext(), &pb.ListHTTPDNSAccessLogsRequest{ + Day: "", + ClusterId: params.ClusterId, + NodeId: params.NodeId, + AppId: strings.TrimSpace(params.AppId), + Domain: strings.TrimSpace(params.Domain), + Status: strings.TrimSpace(params.Status), + Keyword: strings.TrimSpace(params.Keyword), + Offset: 0, + Size: 100, + }) + if err != nil { + this.ErrorPage(err) + return + } + + logs := make([]map[string]interface{}, 0, len(logResp.GetLogs())) + for _, item := range logResp.GetLogs() { + createdTime := "" + if item.GetCreatedAt() > 0 { + createdTime = timeutil.FormatTime("Y-m-d H:i:s", item.GetCreatedAt()) + } + status := item.GetStatus() + if len(status) == 0 { + status = "failed" + } + errorCode := item.GetErrorCode() + if len(errorCode) == 0 { + errorCode = "none" + } + + logs = append(logs, map[string]interface{}{ + "time": createdTime, + "clusterId": item.GetClusterId(), + "clusterName": item.GetClusterName(), + "nodeId": item.GetNodeId(), + "nodeName": item.GetNodeName(), + "appName": item.GetAppName(), + "appId": item.GetAppId(), + "domain": item.GetDomain(), + "query": item.GetQtype(), + "clientIp": item.GetClientIP(), + "os": item.GetOs(), + "sdkVersion": item.GetSdkVersion(), + "ips": item.GetResultIPs(), + "status": status, + "errorCode": errorCode, + "costMs": item.GetCostMs(), + }) + } + + this.Data["resolveLogs"] = logs + this.Show() +} + diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/resolveLogs/init.go b/EdgeAdmin/internal/web/actions/default/httpdns/resolveLogs/init.go new file mode 100644 index 0000000..da6afb4 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/resolveLogs/init.go @@ -0,0 +1,19 @@ +package resolveLogs + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/configloaders" + "github.com/TeaOSLab/EdgeAdmin/internal/web/helpers" + "github.com/iwind/TeaGo" +) + +func init() { + TeaGo.BeforeStart(func(server *TeaGo.Server) { + server. + Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)). + Data("teaMenu", "httpdns"). + Data("teaSubMenu", "resolveLogs"). + Prefix("/httpdns/resolveLogs"). + Get("", new(IndexAction)). + EndAll() + }) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/runtimeLogs/index.go b/EdgeAdmin/internal/web/actions/default/httpdns/runtimeLogs/index.go new file mode 100644 index 0000000..6d2071e --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/runtimeLogs/index.go @@ -0,0 +1,116 @@ +package runtimeLogs + +import ( + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + timeutil "github.com/iwind/TeaGo/utils/time" +) + +type IndexAction struct { + actionutils.ParentAction +} + +func (this *IndexAction) Init() { + this.Nav("httpdns", "runtimeLogs", "") +} + +func (this *IndexAction) RunGet(params struct { + ClusterId int64 + NodeId int64 + DayFrom string + DayTo string + Level string + Keyword string +}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + if params.ClusterId > 0 { + this.Data["clusterId"] = params.ClusterId + } else { + this.Data["clusterId"] = "" + } + if params.NodeId > 0 { + this.Data["nodeId"] = params.NodeId + } else { + this.Data["nodeId"] = "" + } + this.Data["dayFrom"] = params.DayFrom + this.Data["dayTo"] = params.DayTo + this.Data["level"] = params.Level + this.Data["keyword"] = params.Keyword + + clusterResp, err := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if err != nil { + this.ErrorPage(err) + return + } + clusters := make([]map[string]interface{}, 0, len(clusterResp.GetClusters())) + nodes := make([]map[string]interface{}, 0) + for _, cluster := range clusterResp.GetClusters() { + clusters = append(clusters, map[string]interface{}{ + "id": cluster.GetId(), + "name": cluster.GetName(), + }) + nodeResp, err := this.RPC().HTTPDNSNodeRPC().ListHTTPDNSNodes(this.AdminContext(), &pb.ListHTTPDNSNodesRequest{ + ClusterId: cluster.GetId(), + }) + if err != nil { + this.ErrorPage(err) + return + } + for _, node := range nodeResp.GetNodes() { + nodes = append(nodes, map[string]interface{}{ + "id": node.GetId(), + "clusterId": node.GetClusterId(), + "name": node.GetName(), + }) + } + } + this.Data["clusters"] = clusters + this.Data["nodes"] = nodes + + day := strings.TrimSpace(params.DayFrom) + if len(day) == 0 { + day = strings.TrimSpace(params.DayTo) + } + logResp, err := this.RPC().HTTPDNSRuntimeLogRPC().ListHTTPDNSRuntimeLogs(this.AdminContext(), &pb.ListHTTPDNSRuntimeLogsRequest{ + Day: day, + ClusterId: params.ClusterId, + NodeId: params.NodeId, + Level: strings.TrimSpace(params.Level), + Keyword: strings.TrimSpace(params.Keyword), + Offset: 0, + Size: 100, + }) + if err != nil { + this.ErrorPage(err) + return + } + + runtimeLogs := make([]map[string]interface{}, 0, len(logResp.GetLogs())) + for _, item := range logResp.GetLogs() { + createdTime := "" + if item.GetCreatedAt() > 0 { + createdTime = timeutil.FormatTime("Y-m-d H:i:s", item.GetCreatedAt()) + } + runtimeLogs = append(runtimeLogs, map[string]interface{}{ + "createdTime": createdTime, + "clusterId": item.GetClusterId(), + "clusterName": item.GetClusterName(), + "nodeId": item.GetNodeId(), + "nodeName": item.GetNodeName(), + "level": item.GetLevel(), + "tag": item.GetType(), + "module": item.GetModule(), + "description": item.GetDescription(), + "count": item.GetCount(), + "requestId": item.GetRequestId(), + }) + } + this.Data["runtimeLogs"] = runtimeLogs + this.Show() +} + diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/runtimeLogs/init.go b/EdgeAdmin/internal/web/actions/default/httpdns/runtimeLogs/init.go new file mode 100644 index 0000000..0816d7b --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/runtimeLogs/init.go @@ -0,0 +1,19 @@ +package runtimeLogs + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/configloaders" + "github.com/TeaOSLab/EdgeAdmin/internal/web/helpers" + "github.com/iwind/TeaGo" +) + +func init() { + TeaGo.BeforeStart(func(server *TeaGo.Server) { + server. + Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)). + Data("teaMenu", "httpdns"). + Data("teaSubMenu", "runtimeLogs"). + Prefix("/httpdns/runtimeLogs"). + Get("", new(IndexAction)). + EndAll() + }) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/sandbox/index.go b/EdgeAdmin/internal/web/actions/default/httpdns/sandbox/index.go new file mode 100644 index 0000000..11f640b --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/sandbox/index.go @@ -0,0 +1,100 @@ +package sandbox + +import ( + "encoding/json" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" +) + +type IndexAction struct { + actionutils.ParentAction +} + +func (this *IndexAction) Init() { + this.Nav("httpdns", "sandbox", "") +} + +func (this *IndexAction) RunGet(params struct{}) { + httpdnsutils.AddLeftMenu(this.Parent()) + + clusterResp, err := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if err != nil { + this.ErrorPage(err) + return + } + clusters := make([]maps.Map, 0, len(clusterResp.GetClusters())) + for _, cluster := range clusterResp.GetClusters() { + serviceDomain := strings.TrimSpace(cluster.GetServiceDomain()) + + port := "443" + if rawTLS := cluster.GetTlsPolicyJSON(); len(rawTLS) > 0 { + var tlsConfig map[string]interface{} + if err := json.Unmarshal(rawTLS, &tlsConfig); err == nil { + if listenRaw, ok := tlsConfig["listen"]; ok && listenRaw != nil { + if data, err := json.Marshal(listenRaw); err == nil { + var listenAddresses []map[string]interface{} + if err := json.Unmarshal(data, &listenAddresses); err == nil { + if len(listenAddresses) > 0 { + if portRange, ok := listenAddresses[0]["portRange"].(string); ok && len(portRange) > 0 { + port = portRange + } + } + } + } + } + } + } + apiAddress := "https://" + serviceDomain + ":" + port + + displayName := apiAddress + if len(serviceDomain) == 0 { + displayName = cluster.GetName() + } + clusters = append(clusters, maps.Map{ + "id": cluster.GetId(), + "name": cluster.GetName(), + "serviceDomain": serviceDomain, + "displayName": displayName, + }) + } + this.Data["clusters"] = clusters + + appResp, err := this.RPC().HTTPDNSAppRPC().FindAllHTTPDNSApps(this.AdminContext(), &pb.FindAllHTTPDNSAppsRequest{}) + if err != nil { + this.ErrorPage(err) + return + } + apps := make([]maps.Map, 0, len(appResp.GetApps())) + for _, app := range appResp.GetApps() { + domainResp, err := this.RPC().HTTPDNSDomainRPC().ListHTTPDNSDomainsWithAppId(this.AdminContext(), &pb.ListHTTPDNSDomainsWithAppIdRequest{ + AppDbId: app.GetId(), + }) + if err != nil { + this.ErrorPage(err) + return + } + domains := make([]string, 0, len(domainResp.GetDomains())) + for _, domain := range domainResp.GetDomains() { + domains = append(domains, domain.GetDomain()) + } + // 解析集群ID列表 + var clusterIds []int64 + if len(app.GetClusterIdsJSON()) > 0 { + _ = json.Unmarshal(app.GetClusterIdsJSON(), &clusterIds) + } + + apps = append(apps, maps.Map{ + "id": app.GetId(), + "name": app.GetName(), + "appId": app.GetAppId(), + "clusterIds": clusterIds, + "domains": domains, + }) + } + this.Data["apps"] = apps + this.Show() +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/sandbox/init.go b/EdgeAdmin/internal/web/actions/default/httpdns/sandbox/init.go new file mode 100644 index 0000000..9a68b4e --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/sandbox/init.go @@ -0,0 +1,20 @@ +package sandbox + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/configloaders" + "github.com/TeaOSLab/EdgeAdmin/internal/web/helpers" + "github.com/iwind/TeaGo" +) + +func init() { + TeaGo.BeforeStart(func(server *TeaGo.Server) { + server. + Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)). + Data("teaMenu", "httpdns"). + Data("teaSubMenu", "sandbox"). + Prefix("/httpdns/sandbox"). + Get("", new(IndexAction)). + Post("/test", new(TestAction)). + EndAll() + }) +} diff --git a/EdgeAdmin/internal/web/actions/default/httpdns/sandbox/test.go b/EdgeAdmin/internal/web/actions/default/httpdns/sandbox/test.go new file mode 100644 index 0000000..5a66eae --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/httpdns/sandbox/test.go @@ -0,0 +1,170 @@ +package sandbox + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/url" + "strconv" + "strings" + "time" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/index/loginutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" + "github.com/iwind/TeaGo/rands" +) + +type TestAction struct { + actionutils.ParentAction +} + +func (this *TestAction) RunPost(params struct { + AppId string + ClusterId int64 + Domain string + ClientIp string + Qtype string +}) { + clientIP := strings.TrimSpace(params.ClientIp) + if len(clientIP) == 0 { + clientIP = strings.TrimSpace(loginutils.RemoteIP(&this.ActionObject)) + } + qtype := strings.ToUpper(strings.TrimSpace(params.Qtype)) + if len(qtype) == 0 { + qtype = "A" + } + + resp, err := this.RPC().HTTPDNSSandboxRPC().TestHTTPDNSResolve(this.AdminContext(), &pb.TestHTTPDNSResolveRequest{ + ClusterId: params.ClusterId, + AppId: strings.TrimSpace(params.AppId), + Domain: strings.TrimSpace(params.Domain), + Qtype: qtype, + ClientIP: clientIP, + Sid: "", + SdkVersion: "", + Os: "", + }) + if err != nil { + this.ErrorPage(err) + return + } + + clusterDomain := "" + port := "443" + if params.ClusterId > 0 { + clusterResp, findErr := this.RPC().HTTPDNSClusterRPC().FindHTTPDNSCluster(this.AdminContext(), &pb.FindHTTPDNSClusterRequest{ + ClusterId: params.ClusterId, + }) + if findErr == nil && clusterResp.GetCluster() != nil { + clusterDomain = strings.TrimSpace(clusterResp.GetCluster().GetServiceDomain()) + if rawTLS := clusterResp.GetCluster().GetTlsPolicyJSON(); len(rawTLS) > 0 { + var tlsConfig map[string]interface{} + if err := json.Unmarshal(rawTLS, &tlsConfig); err == nil { + if listenRaw, ok := tlsConfig["listen"]; ok && listenRaw != nil { + if data, err := json.Marshal(listenRaw); err == nil { + var listenAddresses []map[string]interface{} + if err := json.Unmarshal(data, &listenAddresses); err == nil { + if len(listenAddresses) > 0 { + if portRange, ok := listenAddresses[0]["portRange"].(string); ok && len(portRange) > 0 { + port = portRange + } + } + } + } + } + } + } + } + } + if len(clusterDomain) == 0 { + clusterDomain = "httpdns.example.com" + } + query := url.Values{} + query.Set("appId", params.AppId) + query.Set("dn", params.Domain) + query.Set("qtype", qtype) + if len(clientIP) > 0 { + query.Set("cip", clientIP) + } + + signEnabled, signSecret := this.findAppSignConfig(params.AppId) + if signEnabled && len(signSecret) > 0 { + exp := strconv.FormatInt(time.Now().Unix()+300, 10) + nonce := "sandbox-" + rands.HexString(16) + sign := buildSandboxResolveSign(signSecret, params.AppId, params.Domain, qtype, exp, nonce) + query.Set("exp", exp) + query.Set("nonce", nonce) + query.Set("sign", sign) + } + requestURL := "https://" + clusterDomain + ":" + port + "/resolve?" + query.Encode() + + resultCode := 1 + if strings.EqualFold(resp.GetCode(), "SUCCESS") { + resultCode = 0 + } + rows := make([]maps.Map, 0, len(resp.GetRecords())) + ips := make([]string, 0, len(resp.GetRecords())) + lineName := strings.TrimSpace(resp.GetClientCarrier()) + if len(lineName) == 0 { + lineName = "-" + } + for _, record := range resp.GetRecords() { + ips = append(ips, record.GetIp()) + if lineName == "-" && len(record.GetLine()) > 0 { + lineName = record.GetLine() + } + rows = append(rows, maps.Map{ + "domain": resp.GetDomain(), + "type": record.GetType(), + "ip": record.GetIp(), + "ttl": record.GetTtl(), + "region": record.GetRegion(), + "line": record.GetLine(), + }) + } + + this.Data["result"] = maps.Map{ + "code": resultCode, + "message": resp.GetMessage(), + "requestId": resp.GetRequestId(), + "data": maps.Map{ + "request_url": requestURL, + "client_ip": resp.GetClientIP(), + "client_region": resp.GetClientRegion(), + "line_name": lineName, + "ips": ips, + "records": rows, + "ttl": resp.GetTtl(), + }, + } + this.Success() +} + +func (this *TestAction) findAppSignConfig(appId string) (bool, string) { + appId = strings.TrimSpace(appId) + if len(appId) == 0 { + return false, "" + } + + resp, err := this.RPC().HTTPDNSAppRPC().FindAllHTTPDNSApps(this.AdminContext(), &pb.FindAllHTTPDNSAppsRequest{}) + if err != nil { + return false, "" + } + + for _, app := range resp.GetApps() { + if strings.EqualFold(strings.TrimSpace(app.GetAppId()), appId) { + return app.GetSignEnabled(), strings.TrimSpace(app.GetSignSecret()) + } + } + return false, "" +} + +func buildSandboxResolveSign(signSecret string, appID string, domain string, qtype string, exp string, nonce string) string { + raw := strings.TrimSpace(appID) + "|" + strings.ToLower(strings.TrimSpace(domain)) + "|" + strings.ToUpper(strings.TrimSpace(qtype)) + "|" + strings.TrimSpace(exp) + "|" + strings.TrimSpace(nonce) + mac := hmac.New(sha256.New, []byte(strings.TrimSpace(signSecret))) + _, _ = mac.Write([]byte(raw)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/EdgeAdmin/internal/web/actions/default/settings/settingutils/helper.go b/EdgeAdmin/internal/web/actions/default/settings/settingutils/helper.go index be3299c..083fae6 100644 --- a/EdgeAdmin/internal/web/actions/default/settings/settingutils/helper.go +++ b/EdgeAdmin/internal/web/actions/default/settings/settingutils/helper.go @@ -39,6 +39,7 @@ func (this *Helper) BeforeAction(actionPtr actions.ActionWrapper) (goNext bool) if configloaders.AllowModule(adminId, configloaders.AdminModuleCodeSetting) { tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabAdminServer), "", "/settings/server", "", this.tab == "server") tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabAdminUI), "", "/settings/ui", "", this.tab == "ui") + tabbar.Add("升级设置", "", "/settings/upgrade", "", this.tab == "upgrade") tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabAdminSecuritySettings), "", "/settings/security", "", this.tab == "security") if teaconst.IsPlus { tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabIPLibrary), "", "/settings/ip-library", "", this.tab == "ipLibrary") diff --git a/EdgeAdmin/internal/web/actions/default/settings/settingutils/helper_plus.go b/EdgeAdmin/internal/web/actions/default/settings/settingutils/helper_plus.go index 5f10787..131a55a 100644 --- a/EdgeAdmin/internal/web/actions/default/settings/settingutils/helper_plus.go +++ b/EdgeAdmin/internal/web/actions/default/settings/settingutils/helper_plus.go @@ -42,6 +42,7 @@ func (this *Helper) BeforeAction(actionPtr actions.ActionWrapper) (goNext bool) if teaconst.IsPlus { tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabUserUI), "", "/settings/user-ui", "", this.tab == "userUI") } + tabbar.Add("升级设置", "", "/settings/upgrade", "", this.tab == "upgrade") tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabAdminSecuritySettings), "", "/settings/security", "", this.tab == "security") tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabIPLibrary), "", "/settings/ip-library", "", this.tab == "ipLibrary") } diff --git a/EdgeAdmin/internal/web/actions/default/settings/ui/index.go b/EdgeAdmin/internal/web/actions/default/settings/ui/index.go index d2de556..f178c8a 100644 --- a/EdgeAdmin/internal/web/actions/default/settings/ui/index.go +++ b/EdgeAdmin/internal/web/actions/default/settings/ui/index.go @@ -56,9 +56,6 @@ func (this *IndexAction) RunPost(params struct { TimeZone string DnsResolverType string - SupportModuleCDN bool - SupportModuleNS bool - Must *actions.Must CSRF *actionutils.CSRF }) { @@ -93,13 +90,7 @@ func (this *IndexAction) RunPost(params struct { config.DefaultPageSize = 10 } - config.Modules = []userconfigs.UserModule{} - if params.SupportModuleCDN { - config.Modules = append(config.Modules, userconfigs.UserModuleCDN) - } - if params.SupportModuleNS { - config.Modules = append(config.Modules, userconfigs.UserModuleNS) - } + config.Modules = []userconfigs.UserModule{userconfigs.UserModuleCDN, userconfigs.UserModuleNS} // 上传Favicon文件 if params.FaviconFile != nil { diff --git a/EdgeAdmin/internal/web/actions/default/settings/upgrade/dns_helper_default.go b/EdgeAdmin/internal/web/actions/default/settings/upgrade/dns_helper_default.go new file mode 100644 index 0000000..9e0c41a --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/settings/upgrade/dns_helper_default.go @@ -0,0 +1,23 @@ +//go:build !plus + +package upgrade + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/iwind/TeaGo/maps" +) + +// loadDNSUpgradeModules 非Plus版本不支持DNS模块 +func loadDNSUpgradeModules(parent *actionutils.ParentAction) []maps.Map { + return nil +} + +// upgradeDNSNode 非Plus版本不支持DNS节点升级 +func upgradeDNSNode(parent *actionutils.ParentAction, nodeId int64) error { + return nil +} + +// loadDNSNodeStatus 非Plus版本不支持DNS节点状态查询 +func loadDNSNodeStatus(parent *actionutils.ParentAction, nodeIds []int64) []maps.Map { + return nil +} diff --git a/EdgeAdmin/internal/web/actions/default/settings/upgrade/dns_helper_plus.go b/EdgeAdmin/internal/web/actions/default/settings/upgrade/dns_helper_plus.go new file mode 100644 index 0000000..653a840 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/settings/upgrade/dns_helper_plus.go @@ -0,0 +1,100 @@ +//go:build plus + +package upgrade + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" +) + +// loadDNSUpgradeModules 加载DNS模块的待升级节点信息 +func loadDNSUpgradeModules(parent *actionutils.ParentAction) []maps.Map { + clustersResp, err := parent.RPC().NSClusterRPC().ListNSClusters(parent.AdminContext(), &pb.ListNSClustersRequest{ + Offset: 0, + Size: 10000, + }) + if err != nil { + return nil + } + + var clusterMaps []maps.Map + for _, cluster := range clustersResp.NsClusters { + nodesResp, err := parent.RPC().NSNodeRPC().FindAllUpgradeNSNodesWithNSClusterId(parent.AdminContext(), &pb.FindAllUpgradeNSNodesWithNSClusterIdRequest{ + NsClusterId: cluster.Id, + }) + if err != nil { + continue + } + var nodeMaps []maps.Map + for _, nodeUpgrade := range nodesResp.Nodes { + if nodeUpgrade.NsNode == nil { + continue + } + installStatusMap := decodeInstallStatusFromPB(nodeUpgrade.NsNode.InstallStatus) + accessIP, login, loginParams := decodeNodeAccessInfo(nodeUpgrade.NsNode.StatusJSON, nodeUpgrade.NsNode.NodeLogin, nodeUpgrade.NsNode.Name) + + nodeMaps = append(nodeMaps, maps.Map{ + "id": nodeUpgrade.NsNode.Id, + "name": nodeUpgrade.NsNode.Name, + "os": nodeUpgrade.Os, + "arch": nodeUpgrade.Arch, + "oldVersion": nodeUpgrade.OldVersion, + "newVersion": nodeUpgrade.NewVersion, + "isOn": nodeUpgrade.NsNode.IsOn, + "isUp": nodeUpgrade.NsNode.IsUp, + "accessIP": accessIP, + "login": login, + "loginParams": loginParams, + "installStatus": installStatusMap, + }) + } + if len(nodeMaps) == 0 { + continue + } + clusterMaps = append(clusterMaps, maps.Map{ + "id": cluster.Id, + "name": cluster.Name, + "nodes": nodeMaps, + "count": len(nodeMaps), + }) + } + return clusterMaps +} + +// upgradeDNSNode 升级DNS节点 +func upgradeDNSNode(parent *actionutils.ParentAction, nodeId int64) error { + _, err := parent.RPC().NSNodeRPC().UpgradeNSNode(parent.AdminContext(), &pb.UpgradeNSNodeRequest{ + NsNodeId: nodeId, + }) + return err +} + +// loadDNSNodeStatus 加载DNS节点安装状态 +func loadDNSNodeStatus(parent *actionutils.ParentAction, nodeIds []int64) []maps.Map { + var result []maps.Map + for _, nodeId := range nodeIds { + resp, err := parent.RPC().NSNodeRPC().FindNSNode(parent.AdminContext(), &pb.FindNSNodeRequest{ + NsNodeId: nodeId, + }) + if err != nil || resp.NsNode == nil { + continue + } + var installStatusMap maps.Map + if resp.NsNode.InstallStatus != nil { + installStatusMap = maps.Map{ + "isRunning": resp.NsNode.InstallStatus.IsRunning, + "isFinished": resp.NsNode.InstallStatus.IsFinished, + "isOk": resp.NsNode.InstallStatus.IsOk, + "error": resp.NsNode.InstallStatus.Error, + "errorCode": resp.NsNode.InstallStatus.ErrorCode, + "updatedAt": resp.NsNode.InstallStatus.UpdatedAt, + } + } + result = append(result, maps.Map{ + "id": nodeId, + "installStatus": installStatusMap, + }) + } + return result +} diff --git a/EdgeAdmin/internal/web/actions/default/settings/upgrade/index.go b/EdgeAdmin/internal/web/actions/default/settings/upgrade/index.go index 66ca047..6997fc8 100644 --- a/EdgeAdmin/internal/web/actions/default/settings/upgrade/index.go +++ b/EdgeAdmin/internal/web/actions/default/settings/upgrade/index.go @@ -1,6 +1,15 @@ package upgrade -import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" +import ( + "encoding/json" + "strings" + + "github.com/TeaOSLab/EdgeAdmin/internal/configloaders" + teaconst "github.com/TeaOSLab/EdgeAdmin/internal/const" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" +) type IndexAction struct { actionutils.ParentAction @@ -11,5 +20,264 @@ func (this *IndexAction) Init() { } func (this *IndexAction) RunGet(params struct{}) { + // 加载升级配置 + config, err := configloaders.LoadUpgradeConfig() + if err != nil { + this.ErrorPage(err) + return + } + this.Data["config"] = config + + // 模块列表 + modules := []maps.Map{} + + // 1. 边缘节点 (EdgeNode) + nodeModule := this.loadEdgeNodeModule() + if nodeModule != nil { + modules = append(modules, nodeModule) + } + + // 2. DNS节点 (EdgeDNS) — 仅Plus版本 + if teaconst.IsPlus { + dnsClusters := loadDNSUpgradeModules(&this.ParentAction) + if len(dnsClusters) > 0 { + totalCount := 0 + for _, c := range dnsClusters { + totalCount += c.GetInt("count") + } + modules = append(modules, maps.Map{ + "name": "DNS节点", + "code": "dns", + "clusters": dnsClusters, + "count": totalCount, + }) + } + } + + // 3. HTTPDNS节点 + httpdnsModule := this.loadHTTPDNSModule() + if httpdnsModule != nil { + modules = append(modules, httpdnsModule) + } + + this.Data["modules"] = modules + this.Show() } + +func (this *IndexAction) RunPost(params struct { + AutoUpgrade bool +}) { + config, err := configloaders.LoadUpgradeConfig() + if err != nil { + this.ErrorPage(err) + return + } + + config.AutoUpgrade = params.AutoUpgrade + + err = configloaders.UpdateUpgradeConfig(config) + if err != nil { + this.ErrorPage(err) + return + } + + this.Success() +} + +// loadEdgeNodeModule 加载边缘节点模块的待升级信息 +func (this *IndexAction) loadEdgeNodeModule() maps.Map { + // 获取所有集群 + clustersResp, err := this.RPC().NodeClusterRPC().ListEnabledNodeClusters(this.AdminContext(), &pb.ListEnabledNodeClustersRequest{ + Offset: 0, + Size: 10000, + }) + if err != nil { + return nil + } + + var clusterMaps []maps.Map + totalCount := 0 + for _, cluster := range clustersResp.NodeClusters { + resp, err := this.RPC().NodeRPC().FindAllUpgradeNodesWithNodeClusterId(this.AdminContext(), &pb.FindAllUpgradeNodesWithNodeClusterIdRequest{ + NodeClusterId: cluster.Id, + }) + if err != nil { + continue + } + if len(resp.Nodes) == 0 { + continue + } + + var nodeMaps []maps.Map + for _, nodeUpgrade := range resp.Nodes { + if nodeUpgrade.Node == nil { + continue + } + installStatusMap := decodeInstallStatusFromPB(nodeUpgrade.Node.InstallStatus) + accessIP, login, loginParams := decodeNodeAccessInfo(nodeUpgrade.Node.StatusJSON, nodeUpgrade.Node.NodeLogin, nodeUpgrade.Node.Name) + + nodeMaps = append(nodeMaps, maps.Map{ + "id": nodeUpgrade.Node.Id, + "name": nodeUpgrade.Node.Name, + "os": nodeUpgrade.Os, + "arch": nodeUpgrade.Arch, + "oldVersion": nodeUpgrade.OldVersion, + "newVersion": nodeUpgrade.NewVersion, + "isOn": nodeUpgrade.Node.IsOn, + "isUp": nodeUpgrade.Node.IsUp, + "accessIP": accessIP, + "login": login, + "loginParams": loginParams, + "installStatus": installStatusMap, + }) + } + totalCount += len(nodeMaps) + clusterMaps = append(clusterMaps, maps.Map{ + "id": cluster.Id, + "name": cluster.Name, + "nodes": nodeMaps, + "count": len(nodeMaps), + }) + } + + if len(clusterMaps) == 0 { + return nil + } + + return maps.Map{ + "name": "边缘节点", + "code": "node", + "clusters": clusterMaps, + "count": totalCount, + } +} + +// loadHTTPDNSModule 加载HTTPDNS模块的待升级信息 +func (this *IndexAction) loadHTTPDNSModule() maps.Map { + clustersResp, err := this.RPC().HTTPDNSClusterRPC().ListHTTPDNSClusters(this.AdminContext(), &pb.ListHTTPDNSClustersRequest{ + Offset: 0, + Size: 10000, + }) + if err != nil { + return nil + } + + var clusterMaps []maps.Map + totalCount := 0 + for _, cluster := range clustersResp.Clusters { + resp, err := this.RPC().HTTPDNSNodeRPC().FindAllUpgradeHTTPDNSNodesWithClusterId(this.AdminContext(), &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdRequest{ + ClusterId: cluster.Id, + }) + if err != nil { + continue + } + if len(resp.Nodes) == 0 { + continue + } + + var nodeMaps []maps.Map + for _, nodeUpgrade := range resp.Nodes { + if nodeUpgrade.Node == nil { + continue + } + installStatusMap := decodeInstallStatusFromJSON(nodeUpgrade.Node.InstallStatusJSON) + accessIP, login, loginParams := decodeNodeAccessInfo(nodeUpgrade.Node.StatusJSON, nodeUpgrade.Node.NodeLogin, nodeUpgrade.Node.Name) + + nodeMaps = append(nodeMaps, maps.Map{ + "id": nodeUpgrade.Node.Id, + "name": nodeUpgrade.Node.Name, + "os": nodeUpgrade.Os, + "arch": nodeUpgrade.Arch, + "oldVersion": nodeUpgrade.OldVersion, + "newVersion": nodeUpgrade.NewVersion, + "isOn": nodeUpgrade.Node.IsOn, + "isUp": nodeUpgrade.Node.IsUp, + "accessIP": accessIP, + "login": login, + "loginParams": loginParams, + "installStatus": installStatusMap, + }) + } + totalCount += len(nodeMaps) + clusterMaps = append(clusterMaps, maps.Map{ + "id": cluster.Id, + "name": cluster.Name, + "nodes": nodeMaps, + "count": len(nodeMaps), + }) + } + + if len(clusterMaps) == 0 { + return nil + } + + return maps.Map{ + "name": "HTTPDNS节点", + "code": "httpdns", + "clusters": clusterMaps, + "count": totalCount, + } +} + +// decodeInstallStatusFromPB 从 protobuf InstallStatus 解码安装状态 +func decodeInstallStatusFromPB(status *pb.NodeInstallStatus) maps.Map { + if status == nil { + return nil + } + // 历史成功状态,在待升级列表中忽略 + if status.IsFinished && status.IsOk { + return nil + } + return maps.Map{ + "isRunning": status.IsRunning, + "isFinished": status.IsFinished, + "isOk": status.IsOk, + "error": status.Error, + "errorCode": status.ErrorCode, + "updatedAt": status.UpdatedAt, + } +} + +// decodeInstallStatusFromJSON 从 JSON 字节解码安装状态 +func decodeInstallStatusFromJSON(raw []byte) maps.Map { + if len(raw) == 0 { + return nil + } + result := maps.Map{} + _ = json.Unmarshal(raw, &result) + + isFinished, _ := result["isFinished"].(bool) + isOk, _ := result["isOk"].(bool) + if isFinished && isOk { + return nil + } + return result +} + +// decodeNodeAccessInfo 从节点状态和登录信息中提取 accessIP、login、loginParams +func decodeNodeAccessInfo(statusJSON []byte, nodeLogin *pb.NodeLogin, nodeName string) (accessIP string, login maps.Map, loginParams maps.Map) { + // 从 statusJSON 中提取 hostIP 作为 accessIP + if len(statusJSON) > 0 { + statusMap := maps.Map{} + _ = json.Unmarshal(statusJSON, &statusMap) + accessIP = strings.TrimSpace(statusMap.GetString("hostIP")) + } + if len(accessIP) == 0 { + accessIP = strings.TrimSpace(nodeName) + } + + // 解码 login 信息 + if nodeLogin != nil { + login = maps.Map{ + "id": nodeLogin.Id, + "name": nodeLogin.Name, + "type": nodeLogin.Type, + } + if len(nodeLogin.Params) > 0 { + loginParams = maps.Map{} + _ = json.Unmarshal(nodeLogin.Params, &loginParams) + } + } + return +} diff --git a/EdgeAdmin/internal/web/actions/default/settings/upgrade/init.go b/EdgeAdmin/internal/web/actions/default/settings/upgrade/init.go index c495cad..322e7cb 100644 --- a/EdgeAdmin/internal/web/actions/default/settings/upgrade/init.go +++ b/EdgeAdmin/internal/web/actions/default/settings/upgrade/init.go @@ -13,7 +13,9 @@ func init() { Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeSetting)). Helper(settingutils.NewHelper("upgrade")). Prefix("/settings/upgrade"). - Get("", new(IndexAction)). + GetPost("", new(IndexAction)). + Post("/upgradeNode", new(UpgradeNodeAction)). + Post("/status", new(StatusAction)). EndAll() }) } diff --git a/EdgeAdmin/internal/web/actions/default/settings/upgrade/status.go b/EdgeAdmin/internal/web/actions/default/settings/upgrade/status.go new file mode 100644 index 0000000..67617a6 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/settings/upgrade/status.go @@ -0,0 +1,80 @@ +package upgrade + +import ( + "encoding/json" + + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" +) + +type StatusAction struct { + actionutils.ParentAction +} + +func (this *StatusAction) RunPost(params struct { + NodeIdsJSON []byte // JSON: {"node": [1,2], "dns": [3], "httpdns": [4,5]} +}) { + var nodeIdsMap map[string][]int64 + if len(params.NodeIdsJSON) > 0 { + _ = json.Unmarshal(params.NodeIdsJSON, &nodeIdsMap) + } + + result := maps.Map{} + + // EdgeNode 状态 + if nodeIds, ok := nodeIdsMap["node"]; ok && len(nodeIds) > 0 { + var nodeStatuses []maps.Map + for _, nodeId := range nodeIds { + resp, err := this.RPC().NodeRPC().FindEnabledNode(this.AdminContext(), &pb.FindEnabledNodeRequest{NodeId: nodeId}) + if err != nil || resp.Node == nil { + continue + } + var installStatusMap maps.Map + if resp.Node.InstallStatus != nil { + installStatusMap = maps.Map{ + "isRunning": resp.Node.InstallStatus.IsRunning, + "isFinished": resp.Node.InstallStatus.IsFinished, + "isOk": resp.Node.InstallStatus.IsOk, + "error": resp.Node.InstallStatus.Error, + "errorCode": resp.Node.InstallStatus.ErrorCode, + "updatedAt": resp.Node.InstallStatus.UpdatedAt, + } + } + nodeStatuses = append(nodeStatuses, maps.Map{ + "id": nodeId, + "installStatus": installStatusMap, + }) + } + result["node"] = nodeStatuses + } + + // DNS 状态 + if nodeIds, ok := nodeIdsMap["dns"]; ok && len(nodeIds) > 0 { + result["dns"] = loadDNSNodeStatus(&this.ParentAction, nodeIds) + } + + // HTTPDNS 状态 + if nodeIds, ok := nodeIdsMap["httpdns"]; ok && len(nodeIds) > 0 { + var nodeStatuses []maps.Map + for _, nodeId := range nodeIds { + resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{NodeId: nodeId}) + if err != nil || resp.Node == nil { + continue + } + var installStatusMap maps.Map + if len(resp.Node.InstallStatusJSON) > 0 { + installStatusMap = maps.Map{} + _ = json.Unmarshal(resp.Node.InstallStatusJSON, &installStatusMap) + } + nodeStatuses = append(nodeStatuses, maps.Map{ + "id": nodeId, + "installStatus": installStatusMap, + }) + } + result["httpdns"] = nodeStatuses + } + + this.Data["statuses"] = result + this.Success() +} diff --git a/EdgeAdmin/internal/web/actions/default/settings/upgrade/upgradeNode.go b/EdgeAdmin/internal/web/actions/default/settings/upgrade/upgradeNode.go new file mode 100644 index 0000000..8bd7d78 --- /dev/null +++ b/EdgeAdmin/internal/web/actions/default/settings/upgrade/upgradeNode.go @@ -0,0 +1,150 @@ +package upgrade + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/iwind/TeaGo/maps" +) + +type UpgradeNodeAction struct { + actionutils.ParentAction +} + +func (this *UpgradeNodeAction) RunPost(params struct { + Module string // node, dns, httpdns + Scope string // all, module, cluster, node + ClusterId int64 + NodeId int64 +}) { + switch params.Scope { + case "node": + err := this.upgradeSingleNode(params.Module, params.NodeId) + if err != nil { + this.ErrorPage(err) + return + } + case "cluster": + err := this.upgradeCluster(params.Module, params.ClusterId) + if err != nil { + this.ErrorPage(err) + return + } + case "module": + err := this.upgradeModule(params.Module) + if err != nil { + this.ErrorPage(err) + return + } + case "all": + _ = this.upgradeModule("node") + _ = this.upgradeModule("dns") + _ = this.upgradeModule("httpdns") + } + + this.Success() +} + +func (this *UpgradeNodeAction) upgradeSingleNode(module string, nodeId int64) error { + switch module { + case "node": + _, err := this.RPC().NodeRPC().UpgradeNode(this.AdminContext(), &pb.UpgradeNodeRequest{NodeId: nodeId}) + return err + case "dns": + return upgradeDNSNode(&this.ParentAction, nodeId) + case "httpdns": + _, err := this.RPC().HTTPDNSNodeRPC().UpgradeHTTPDNSNode(this.AdminContext(), &pb.UpgradeHTTPDNSNodeRequest{NodeId: nodeId}) + return err + } + return nil +} + +func (this *UpgradeNodeAction) upgradeCluster(module string, clusterId int64) error { + switch module { + case "node": + resp, err := this.RPC().NodeRPC().FindAllUpgradeNodesWithNodeClusterId(this.AdminContext(), &pb.FindAllUpgradeNodesWithNodeClusterIdRequest{ + NodeClusterId: clusterId, + }) + if err != nil { + return err + } + for _, nodeUpgrade := range resp.Nodes { + if nodeUpgrade.Node == nil { + continue + } + _, _ = this.RPC().NodeRPC().UpgradeNode(this.AdminContext(), &pb.UpgradeNodeRequest{NodeId: nodeUpgrade.Node.Id}) + } + case "dns": + this.upgradeDNSCluster(clusterId) + case "httpdns": + resp, err := this.RPC().HTTPDNSNodeRPC().FindAllUpgradeHTTPDNSNodesWithClusterId(this.AdminContext(), &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdRequest{ + ClusterId: clusterId, + }) + if err != nil { + return err + } + for _, nodeUpgrade := range resp.Nodes { + if nodeUpgrade.Node == nil { + continue + } + _, _ = this.RPC().HTTPDNSNodeRPC().UpgradeHTTPDNSNode(this.AdminContext(), &pb.UpgradeHTTPDNSNodeRequest{NodeId: nodeUpgrade.Node.Id}) + } + } + return nil +} + +func (this *UpgradeNodeAction) upgradeModule(module string) error { + switch module { + case "node": + clustersResp, err := this.RPC().NodeClusterRPC().ListEnabledNodeClusters(this.AdminContext(), &pb.ListEnabledNodeClustersRequest{ + Offset: 0, + Size: 10000, + }) + if err != nil { + return err + } + for _, cluster := range clustersResp.NodeClusters { + _ = this.upgradeCluster("node", cluster.Id) + } + case "dns": + dnsClusters := loadDNSUpgradeModules(&this.ParentAction) + for _, c := range dnsClusters { + this.upgradeDNSClusterFromMap(c) + } + case "httpdns": + clustersResp, err := this.RPC().HTTPDNSClusterRPC().ListHTTPDNSClusters(this.AdminContext(), &pb.ListHTTPDNSClustersRequest{ + Offset: 0, + Size: 10000, + }) + if err != nil { + return err + } + for _, cluster := range clustersResp.Clusters { + _ = this.upgradeCluster("httpdns", cluster.Id) + } + } + return nil +} + +// upgradeDNSCluster 根据集群ID升级DNS节点 +func (this *UpgradeNodeAction) upgradeDNSCluster(clusterId int64) { + dnsClusters := loadDNSUpgradeModules(&this.ParentAction) + for _, c := range dnsClusters { + if c.GetInt64("id") == clusterId { + this.upgradeDNSClusterFromMap(c) + break + } + } +} + +// upgradeDNSClusterFromMap 从maps.Map中提取节点ID并升级 +func (this *UpgradeNodeAction) upgradeDNSClusterFromMap(c maps.Map) { + nodesVal := c.Get("nodes") + if nodeMaps, ok := nodesVal.([]maps.Map); ok { + for _, n := range nodeMaps { + nodeId := n.GetInt64("id") + if nodeId > 0 { + _ = upgradeDNSNode(&this.ParentAction, nodeId) + } + } + } +} diff --git a/EdgeAdmin/internal/web/actions/default/users/createPopup.go b/EdgeAdmin/internal/web/actions/default/users/createPopup.go index 889efe8..54b3ef9 100644 --- a/EdgeAdmin/internal/web/actions/default/users/createPopup.go +++ b/EdgeAdmin/internal/web/actions/default/users/createPopup.go @@ -23,6 +23,34 @@ func (this *CreatePopupAction) Init() { } func (this *CreatePopupAction) RunGet(params struct{}) { + // 检查是否启用了 HTTPDNS 功能(全局用户注册设置中) + var hasHTTPDNSFeature = false + + resp, err := this.RPC().SysSettingRPC().ReadSysSetting(this.AdminContext(), &pb.ReadSysSettingRequest{Code: systemconfigs.SettingCodeUserRegisterConfig}) + if err == nil && len(resp.ValueJSON) > 0 { + var config = userconfigs.DefaultUserRegisterConfig() + if json.Unmarshal(resp.ValueJSON, config) == nil { + hasHTTPDNSFeature = config.HTTPDNSIsOn + } + } + this.Data["hasHTTPDNSFeature"] = hasHTTPDNSFeature + this.Data["httpdnsClusterId"] = 0 + + // 加载所有 HTTPDNS 集群 + var httpdnsClusters = []maps.Map{} + if hasHTTPDNSFeature { + httpdnsClusterResp, err := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if err == nil { + for _, c := range httpdnsClusterResp.GetClusters() { + httpdnsClusters = append(httpdnsClusters, maps.Map{ + "id": c.GetId(), + "name": c.GetName(), + }) + } + } + } + this.Data["httpdnsClusters"] = httpdnsClusters + this.Show() } @@ -35,8 +63,9 @@ func (this *CreatePopupAction) RunPost(params struct { Tel string Email string Remark string - ClusterId int64 - FeaturesType string + ClusterId int64 + HttpdnsClusterId int64 + FeaturesType string // OTP OtpOn bool @@ -111,6 +140,28 @@ func (this *CreatePopupAction) RunPost(params struct { userId = createResp.UserId + // 如果表单选择了 HTTPDNS 关联集群,在这里予以保存 + if params.HttpdnsClusterId > 0 { + httpdnsJSON, _ := json.Marshal([]int64{params.HttpdnsClusterId}) + _, err = this.RPC().UserRPC().UpdateUser(this.AdminContext(), &pb.UpdateUserRequest{ + UserId: userId, + Username: params.Username, + Password: params.Pass1, + Fullname: params.Fullname, + Mobile: params.Mobile, + Tel: params.Tel, + Email: params.Email, + Remark: params.Remark, + IsOn: true, + NodeClusterId: params.ClusterId, + HttpdnsClusterIdsJSON: httpdnsJSON, + }) + if err != nil { + this.ErrorPage(err) + return + } + } + // 功能 if teaconst.IsPlus { if params.FeaturesType == "default" { @@ -127,14 +178,38 @@ func (this *CreatePopupAction) RunPost(params struct { this.ErrorPage(err) return } + var featureCodes = config.Features + _, err = this.RPC().UserRPC().UpdateUserFeatures(this.AdminContext(), &pb.UpdateUserFeaturesRequest{ UserId: userId, - FeatureCodes: config.Features, + FeatureCodes: featureCodes, }) if err != nil { this.ErrorPage(err) return } + + // 自动关联默认 HTTPDNS 集群 (如果没有在表单中选择的话) + if config.HTTPDNSIsOn && params.HttpdnsClusterId <= 0 && len(config.HTTPDNSDefaultClusterIds) > 0 { + httpdnsJSON, _ := json.Marshal(config.HTTPDNSDefaultClusterIds) + _, err = this.RPC().UserRPC().UpdateUser(this.AdminContext(), &pb.UpdateUserRequest{ + UserId: userId, + Username: params.Username, + Password: params.Pass1, + Fullname: params.Fullname, + Mobile: params.Mobile, + Tel: params.Tel, + Email: params.Email, + Remark: params.Remark, + IsOn: true, + NodeClusterId: params.ClusterId, + HttpdnsClusterIdsJSON: httpdnsJSON, + }) + if err != nil { + this.ErrorPage(err) + return + } + } } } else if params.FeaturesType == "all" { featuresResp, err := this.RPC().UserRPC().FindAllUserFeatureDefinitions(this.AdminContext(), &pb.FindAllUserFeatureDefinitionsRequest{}) diff --git a/EdgeAdmin/internal/web/actions/default/users/setting/index.go b/EdgeAdmin/internal/web/actions/default/users/setting/index.go index 11240aa..77f120b 100644 --- a/EdgeAdmin/internal/web/actions/default/users/setting/index.go +++ b/EdgeAdmin/internal/web/actions/default/users/setting/index.go @@ -66,6 +66,28 @@ func (this *IndexAction) RunGet(params struct{}) { // 当前默认的智能DNS设置 this.Data["nsIsVisible"] = plus.AllowComponent(plus.ComponentCodeNS) + // HTTPDNS 集群列表(用于默认集群多选) + httpdnsClusterResp, err := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if err != nil { + this.ErrorPage(err) + return + } + httpdnsClusters := make([]maps.Map, 0, len(httpdnsClusterResp.GetClusters())) + for _, cluster := range httpdnsClusterResp.GetClusters() { + httpdnsClusters = append(httpdnsClusters, maps.Map{ + "id": cluster.GetId(), + "name": cluster.GetName(), + }) + } + this.Data["httpdnsClusters"] = httpdnsClusters + + // 当前选中的默认集群(取数组第一个元素) + var httpdnsDefaultClusterId int64 + if len(config.HTTPDNSDefaultClusterIds) > 0 { + httpdnsDefaultClusterId = config.HTTPDNSDefaultClusterIds[0] + } + this.Data["httpdnsDefaultClusterId"] = httpdnsDefaultClusterId + this.Show() } @@ -102,6 +124,9 @@ func (this *IndexAction) RunPost(params struct { NsIsOn bool + HttpdnsIsOn bool + HttpdnsDefaultClusterId int64 + Must *actions.Must CSRF *actionutils.CSRF }) { @@ -112,6 +137,15 @@ func (this *IndexAction) RunPost(params struct { Gt(0, "请选择一个集群") var config = userconfigs.DefaultUserRegisterConfig() + { + // 先读取现有配置,避免保存时把未出现在当前表单里的字段重置为默认值 + resp, err := this.RPC().SysSettingRPC().ReadSysSetting(this.AdminContext(), &pb.ReadSysSettingRequest{ + Code: systemconfigs.SettingCodeUserRegisterConfig, + }) + if err == nil && len(resp.ValueJSON) > 0 { + _ = json.Unmarshal(resp.ValueJSON, config) + } + } config.IsOn = params.IsOn config.ComplexPassword = params.ComplexPassword config.RequireVerification = params.RequireVerification @@ -142,6 +176,12 @@ func (this *IndexAction) RunPost(params struct { config.ADIsOn = params.AdIsOn config.NSIsOn = params.NsIsOn + config.HTTPDNSIsOn = params.HttpdnsIsOn + if params.HttpdnsDefaultClusterId > 0 { + config.HTTPDNSDefaultClusterIds = []int64{params.HttpdnsDefaultClusterId} + } else { + config.HTTPDNSDefaultClusterIds = []int64{} + } configJSON, err := json.Marshal(config) if err != nil { @@ -157,10 +197,15 @@ func (this *IndexAction) RunPost(params struct { return } - if params.FeatureOp != "keep" { + featureOp := params.FeatureOp + if featureOp != "overwrite" && featureOp != "append" && featureOp != "keep" { + featureOp = "keep" + } + + if featureOp != "keep" { _, err = this.RPC().UserRPC().UpdateAllUsersFeatures(this.AdminContext(), &pb.UpdateAllUsersFeaturesRequest{ FeatureCodes: params.Features, - Overwrite: params.FeatureOp == "overwrite", + Overwrite: featureOp == "overwrite", }) if err != nil { this.ErrorPage(err) diff --git a/EdgeAdmin/internal/web/actions/default/users/update.go b/EdgeAdmin/internal/web/actions/default/users/update.go index 8ab6687..2347ef7 100644 --- a/EdgeAdmin/internal/web/actions/default/users/update.go +++ b/EdgeAdmin/internal/web/actions/default/users/update.go @@ -1,10 +1,14 @@ package users import ( + "encoding/json" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/users/userutils" "github.com/TeaOSLab/EdgeCommon/pkg/langs/codes" "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/TeaOSLab/EdgeCommon/pkg/systemconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/userconfigs" "github.com/iwind/TeaGo/actions" "github.com/iwind/TeaGo/maps" "github.com/xlzd/gotp" @@ -85,6 +89,42 @@ func (this *UpdateAction) RunGet(params struct { this.Data["clusterId"] = user.NodeCluster.Id } + // 检查全局是否启用了 HTTPDNS 功能 + var hasHTTPDNSFeature = false + sysResp, sysErr := this.RPC().SysSettingRPC().ReadSysSetting(this.AdminContext(), &pb.ReadSysSettingRequest{Code: systemconfigs.SettingCodeUserRegisterConfig}) + if sysErr == nil && len(sysResp.ValueJSON) > 0 { + var regConfig = userconfigs.DefaultUserRegisterConfig() + if json.Unmarshal(sysResp.ValueJSON, regConfig) == nil { + hasHTTPDNSFeature = regConfig.HTTPDNSIsOn + } + } + this.Data["hasHTTPDNSFeature"] = hasHTTPDNSFeature + + // 读取用户已关联的 HTTPDNS 集群(取第一个作为下拉默认值) + var httpdnsClusterId int64 + if len(user.HttpdnsClusterIdsJSON) > 0 { + var userHTTPDNSClusterIds []int64 + if json.Unmarshal(user.HttpdnsClusterIdsJSON, &userHTTPDNSClusterIds) == nil && len(userHTTPDNSClusterIds) > 0 { + httpdnsClusterId = userHTTPDNSClusterIds[0] + } + } + this.Data["httpdnsClusterId"] = httpdnsClusterId + + // 加载所有 HTTPDNS 集群 + httpdnsClusterResp, err := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if err != nil { + this.ErrorPage(err) + return + } + httpdnsClusters := make([]maps.Map, 0, len(httpdnsClusterResp.GetClusters())) + for _, c := range httpdnsClusterResp.GetClusters() { + httpdnsClusters = append(httpdnsClusters, maps.Map{ + "id": c.GetId(), + "name": c.GetName(), + }) + } + this.Data["httpdnsClusters"] = httpdnsClusters + this.Show() } @@ -99,8 +139,9 @@ func (this *UpdateAction) RunPost(params struct { Email string Remark string IsOn bool - ClusterId int64 - BandwidthAlgo string + ClusterId int64 + HttpdnsClusterId int64 + BandwidthAlgo string // OTP OtpOn bool @@ -151,18 +192,25 @@ func (this *UpdateAction) RunPost(params struct { Email("请输入正确的电子邮箱") } + var httpdnsClusterIds []int64 + if params.HttpdnsClusterId > 0 { + httpdnsClusterIds = []int64{params.HttpdnsClusterId} + } + httpdnsClusterIdsJSON, _ := json.Marshal(httpdnsClusterIds) + _, err = this.RPC().UserRPC().UpdateUser(this.AdminContext(), &pb.UpdateUserRequest{ - UserId: params.UserId, - Username: params.Username, - Password: params.Pass1, - Fullname: params.Fullname, - Mobile: params.Mobile, - Tel: params.Tel, - Email: params.Email, - Remark: params.Remark, - IsOn: params.IsOn, - NodeClusterId: params.ClusterId, - BandwidthAlgo: params.BandwidthAlgo, + UserId: params.UserId, + Username: params.Username, + Password: params.Pass1, + Fullname: params.Fullname, + Mobile: params.Mobile, + Tel: params.Tel, + Email: params.Email, + Remark: params.Remark, + IsOn: params.IsOn, + NodeClusterId: params.ClusterId, + BandwidthAlgo: params.BandwidthAlgo, + HttpdnsClusterIdsJSON: httpdnsClusterIdsJSON, }) if err != nil { this.ErrorPage(err) diff --git a/EdgeAdmin/internal/web/actions/default/users/user.go b/EdgeAdmin/internal/web/actions/default/users/user.go index f42daa6..72e2377 100644 --- a/EdgeAdmin/internal/web/actions/default/users/user.go +++ b/EdgeAdmin/internal/web/actions/default/users/user.go @@ -6,6 +6,8 @@ import ( "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/users/userutils" "github.com/TeaOSLab/EdgeCommon/pkg/iplibrary" "github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb" + "github.com/TeaOSLab/EdgeCommon/pkg/systemconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/userconfigs" "github.com/iwind/TeaGo/maps" ) @@ -89,6 +91,37 @@ func (this *UserAction) RunGet(params struct { } } + // 检查全局是否启用了 HTTPDNS 功能 + var hasHTTPDNSFeature = false + sysResp, sysErr := this.RPC().SysSettingRPC().ReadSysSetting(this.AdminContext(), &pb.ReadSysSettingRequest{Code: systemconfigs.SettingCodeUserRegisterConfig}) + if sysErr == nil && len(sysResp.ValueJSON) > 0 { + var config = userconfigs.DefaultUserRegisterConfig() + if json.Unmarshal(sysResp.ValueJSON, config) == nil { + hasHTTPDNSFeature = config.HTTPDNSIsOn + } + } + this.Data["hasHTTPDNSFeature"] = hasHTTPDNSFeature + + // 读取用户关联的 HTTPDNS 集群 + var httpdnsClusterMap maps.Map = nil + if hasHTTPDNSFeature && len(user.HttpdnsClusterIdsJSON) > 0 { + var httpdnsClusterIds []int64 + if json.Unmarshal(user.HttpdnsClusterIdsJSON, &httpdnsClusterIds) == nil && len(httpdnsClusterIds) > 0 { + httpdnsClusterResp, httpdnsErr := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.AdminContext(), &pb.FindAllHTTPDNSClustersRequest{}) + if httpdnsErr == nil { + for _, c := range httpdnsClusterResp.GetClusters() { + if c.GetId() == httpdnsClusterIds[0] { + httpdnsClusterMap = maps.Map{ + "id": c.GetId(), + "name": c.GetName(), + } + break + } + } + } + } + } + this.Data["user"] = maps.Map{ "id": user.Id, "username": user.Username, @@ -100,6 +133,7 @@ func (this *UserAction) RunGet(params struct { "mobile": user.Mobile, "isOn": user.IsOn, "cluster": clusterMap, + "httpdnsCluster": httpdnsClusterMap, "countAccessKeys": countAccessKeys, "isRejected": user.IsRejected, "rejectReason": user.RejectReason, diff --git a/EdgeAdmin/internal/web/helpers/menu.go b/EdgeAdmin/internal/web/helpers/menu.go index 5712079..b50237b 100644 --- a/EdgeAdmin/internal/web/helpers/menu.go +++ b/EdgeAdmin/internal/web/helpers/menu.go @@ -137,6 +137,40 @@ func FindAllMenuMaps(langCode string, nodeLogsType string, countUnreadNodeLogs i }, }, }, + { + "code": "httpdns", + "module": configloaders.AdminModuleCodeHttpDNS, + "name": "HTTPDNS", + "subtitle": "", + "icon": "shield alternate", + "subItems": []maps.Map{ + { + "name": "集群管理", + "url": "/httpdns/clusters", + "code": "cluster", + }, + { + "name": "应用管理", + "url": "/httpdns/apps", + "code": "app", + }, + { + "name": "访问日志", + "url": "/httpdns/resolveLogs", + "code": "resolveLogs", + }, + { + "name": "运行日志", + "url": "/httpdns/runtimeLogs", + "code": "runtimeLogs", + }, + { + "name": "解析测试", + "url": "/httpdns/sandbox", + "code": "sandbox", + }, + }, + }, { "code": "users", "module": configloaders.AdminModuleCodeUser, diff --git a/EdgeAdmin/internal/web/helpers/menu_plus.go b/EdgeAdmin/internal/web/helpers/menu_plus.go index 9e40652..a178d61 100644 --- a/EdgeAdmin/internal/web/helpers/menu_plus.go +++ b/EdgeAdmin/internal/web/helpers/menu_plus.go @@ -275,6 +275,40 @@ func FindAllMenuMaps(langCode string, nodeLogsType string, countUnreadNodeLogs i }, }, }, + { + "code": "httpdns", + "module": configloaders.AdminModuleCodeHttpDNS, + "name": "HTTPDNS", + "subtitle": "", + "icon": "shield alternate", + "subItems": []maps.Map{ + { + "name": "集群管理", + "url": "/httpdns/clusters", + "code": "cluster", + }, + { + "name": "应用管理", + "url": "/httpdns/apps", + "code": "app", + }, + { + "name": "访问日志", + "url": "/httpdns/resolveLogs", + "code": "resolveLogs", + }, + { + "name": "运行日志", + "url": "/httpdns/runtimeLogs", + "code": "runtimeLogs", + }, + { + "name": "解析测试", + "url": "/httpdns/sandbox", + "code": "sandbox", + }, + }, + }, { "code": "users", "module": configloaders.AdminModuleCodeUser, @@ -433,3 +467,4 @@ func FindAllMenuMaps(langCode string, nodeLogsType string, countUnreadNodeLogs i }, } } + diff --git a/EdgeAdmin/internal/web/import.go b/EdgeAdmin/internal/web/import.go index 6cd88b9..73167f8 100644 --- a/EdgeAdmin/internal/web/import.go +++ b/EdgeAdmin/internal/web/import.go @@ -136,4 +136,13 @@ import ( // 平台用户 _ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/users" + + // HTTPDNS体系 + _ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns" + _ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/apps" + _ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/clusters" + _ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/clusters" + _ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/resolveLogs" + _ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/runtimeLogs" + _ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/sandbox" ) diff --git a/EdgeAdmin/test_db.go b/EdgeAdmin/test_db.go new file mode 100644 index 0000000..02ecb46 --- /dev/null +++ b/EdgeAdmin/test_db.go @@ -0,0 +1 @@ +package main; import ("fmt"; "github.com/TeaOSLab/EdgeAPI/internal/db/models"; "github.com/iwind/TeaGo/dbs"; "github.com/iwind/TeaGo/Tea"); func main() { Tea.Env = "prod"; dbs.OnReady(func(){ apps, _ := models.SharedHTTPDNSAppDAO.FindAllEnabledApps(nil); for _, app := range apps { fmt.Printf("App: %s, Primary: %d\n", app.AppId, app.PrimaryClusterId) } }); } diff --git a/EdgeAdmin/test_db2.go b/EdgeAdmin/test_db2.go new file mode 100644 index 0000000..9f26f1c --- /dev/null +++ b/EdgeAdmin/test_db2.go @@ -0,0 +1 @@ +package main; import ("fmt"; "github.com/TeaOSLab/EdgeAPI/internal/db/models"; _ "github.com/go-sql-driver/mysql"; "github.com/iwind/TeaGo/dbs"; "github.com/iwind/TeaGo/Tea"); func main() { Tea.Env = "prod"; dbConfig := &dbs.Config{Driver: "mysql", Dsn: "edge:123456@tcp(127.0.0.1:3306)/edge?charset=utf8mb4&parseTime=true&loc=Local", Prefix: "edge"}; dbs.DefaultDB(dbConfig); apps, _ := models.SharedHTTPDNSAppDAO.FindAllEnabledApps(nil); for _, app := range apps { fmt.Printf("App: %s, Primary: %d\n", app.AppId, app.PrimaryClusterId) } } diff --git a/EdgeAdmin/web/public/js/components.js b/EdgeAdmin/web/public/js/components.js index e5f74f0..e333e92 100644 --- a/EdgeAdmin/web/public/js/components.js +++ b/EdgeAdmin/web/public/js/components.js @@ -531,6 +531,57 @@ Vue.component("ddos-protection-ports-config-box", { ` }) +Vue.component("httpdns-clusters-selector", { + props: ["vClusters", "vName"], + data: function () { + let inputClusters = this.vClusters + let clusters = [] + + if (inputClusters != null && inputClusters.length > 0) { + if (inputClusters[0].isChecked !== undefined) { + // 带 isChecked 标志的完整集群列表 + clusters = inputClusters.map(function (c) { + return {id: c.id, name: c.name, isChecked: c.isChecked} + }) + } else { + // 仅包含已选集群,全部标记为选中 + clusters = inputClusters.map(function (c) { + return {id: c.id, name: c.name, isChecked: true} + }) + } + } + + // 无 prop 时从根实例读取所有集群(如创建应用页面) + if (clusters.length === 0) { + let rootClusters = this.$root.clusters + if (rootClusters != null && rootClusters.length > 0) { + clusters = rootClusters.map(function (c) { + return {id: c.id, name: c.name, isChecked: false} + }) + } + } + + return { + clusters: clusters, + fieldName: this.vName || "clusterIds" + } + }, + methods: { + changeCluster: function (cluster) { + cluster.isChecked = !cluster.isChecked + } + }, + template: `
+
+ + {{cluster.name}} + +
+ 暂无可用集群 +
` +}) + + Vue.component("node-cluster-combo-box", { props: ["v-cluster-id"], data: function () { @@ -21995,15 +22046,16 @@ Vue.component("ssl-certs-box", { {{description}}
-
+
  - |   + |    
` }) + Vue.component("ssl-certs-view", { props: ["v-certs"], data: function () { @@ -22467,12 +22519,16 @@ Vue.component("ssl-config-box", { 选择或上传证书后HTTPSTLS服务才能生效。
-   - |   -   -   - |   - +
+ + | + + + +
diff --git a/EdgeAdmin/web/public/js/components.src.js b/EdgeAdmin/web/public/js/components.src.js index e5f74f0..e333e92 100644 --- a/EdgeAdmin/web/public/js/components.src.js +++ b/EdgeAdmin/web/public/js/components.src.js @@ -531,6 +531,57 @@ Vue.component("ddos-protection-ports-config-box", { ` }) +Vue.component("httpdns-clusters-selector", { + props: ["vClusters", "vName"], + data: function () { + let inputClusters = this.vClusters + let clusters = [] + + if (inputClusters != null && inputClusters.length > 0) { + if (inputClusters[0].isChecked !== undefined) { + // 带 isChecked 标志的完整集群列表 + clusters = inputClusters.map(function (c) { + return {id: c.id, name: c.name, isChecked: c.isChecked} + }) + } else { + // 仅包含已选集群,全部标记为选中 + clusters = inputClusters.map(function (c) { + return {id: c.id, name: c.name, isChecked: true} + }) + } + } + + // 无 prop 时从根实例读取所有集群(如创建应用页面) + if (clusters.length === 0) { + let rootClusters = this.$root.clusters + if (rootClusters != null && rootClusters.length > 0) { + clusters = rootClusters.map(function (c) { + return {id: c.id, name: c.name, isChecked: false} + }) + } + } + + return { + clusters: clusters, + fieldName: this.vName || "clusterIds" + } + }, + methods: { + changeCluster: function (cluster) { + cluster.isChecked = !cluster.isChecked + } + }, + template: `
+
+ + {{cluster.name}} + +
+ 暂无可用集群 +
` +}) + + Vue.component("node-cluster-combo-box", { props: ["v-cluster-id"], data: function () { @@ -21995,15 +22046,16 @@ Vue.component("ssl-certs-box", { {{description}}
-
+
  - |   + |    
` }) + Vue.component("ssl-certs-view", { props: ["v-certs"], data: function () { @@ -22467,12 +22519,16 @@ Vue.component("ssl-config-box", { 选择或上传证书后HTTPSTLS服务才能生效。
-   - |   -   -   - |   - +
+ + | + + + +
diff --git a/EdgeAdmin/web/public/js/components/cluster/httpdns-clusters-selector.js b/EdgeAdmin/web/public/js/components/cluster/httpdns-clusters-selector.js new file mode 100644 index 0000000..fb67aed --- /dev/null +++ b/EdgeAdmin/web/public/js/components/cluster/httpdns-clusters-selector.js @@ -0,0 +1,49 @@ +Vue.component("httpdns-clusters-selector", { + props: ["vClusters", "vName"], + data: function () { + let inputClusters = this.vClusters + let clusters = [] + + if (inputClusters != null && inputClusters.length > 0) { + if (inputClusters[0].isChecked !== undefined) { + // 带 isChecked 标志的完整集群列表 + clusters = inputClusters.map(function (c) { + return {id: c.id, name: c.name, isChecked: c.isChecked} + }) + } else { + // 仅包含已选集群,全部标记为选中 + clusters = inputClusters.map(function (c) { + return {id: c.id, name: c.name, isChecked: true} + }) + } + } + + // 无 prop 时从根实例读取所有集群(如创建应用页面) + if (clusters.length === 0) { + let rootClusters = this.$root.clusters + if (rootClusters != null && rootClusters.length > 0) { + clusters = rootClusters.map(function (c) { + return {id: c.id, name: c.name, isChecked: false} + }) + } + } + + return { + clusters: clusters, + fieldName: this.vName || "clusterIds" + } + }, + methods: { + changeCluster: function (cluster) { + cluster.isChecked = !cluster.isChecked + } + }, + template: `
+
+ + {{cluster.name}} + +
+ 暂无可用集群 +
` +}) diff --git a/EdgeAdmin/web/public/js/components/server/ssl-certs-box.js b/EdgeAdmin/web/public/js/components/server/ssl-certs-box.js index 04846c0..51ac768 100644 --- a/EdgeAdmin/web/public/js/components/server/ssl-certs-box.js +++ b/EdgeAdmin/web/public/js/components/server/ssl-certs-box.js @@ -160,11 +160,11 @@ Vue.component("ssl-certs-box", { {{description}}
-
+
  - |   + |    
` -}) \ No newline at end of file +}) diff --git a/EdgeAdmin/web/public/js/components/server/ssl-config-box.js b/EdgeAdmin/web/public/js/components/server/ssl-config-box.js index e1cf825..786879b 100644 --- a/EdgeAdmin/web/public/js/components/server/ssl-config-box.js +++ b/EdgeAdmin/web/public/js/components/server/ssl-config-box.js @@ -427,12 +427,16 @@ Vue.component("ssl-config-box", { 选择或上传证书后HTTPSTLS服务才能生效。
-   - |   -   -   - |   - +
+ + | + + + +
diff --git a/EdgeAdmin/web/public/js/sweetalert2/dist/sweetalert2.css b/EdgeAdmin/web/public/js/sweetalert2/dist/sweetalert2.css index 380c5b1..5527431 100644 --- a/EdgeAdmin/web/public/js/sweetalert2/dist/sweetalert2.css +++ b/EdgeAdmin/web/public/js/sweetalert2/dist/sweetalert2.css @@ -7,78 +7,96 @@ background: #fff; box-shadow: 0 0 0.625em #d9d9d9; } + .swal2-popup.swal2-toast .swal2-header { flex-direction: row; } + .swal2-popup.swal2-toast .swal2-title { flex-grow: 1; justify-content: flex-start; margin: 0 0.6em; font-size: 1em; } + .swal2-popup.swal2-toast .swal2-footer { margin: 0.5em 0 0; padding: 0.5em 0 0; font-size: 0.8em; } + .swal2-popup.swal2-toast .swal2-close { position: static; width: 0.8em; height: 0.8em; line-height: 0.8; } + .swal2-popup.swal2-toast .swal2-content { justify-content: flex-start; font-size: 1em; } + .swal2-popup.swal2-toast .swal2-icon { width: 2em; min-width: 2em; height: 2em; margin: 0; } + .swal2-popup.swal2-toast .swal2-icon .swal2-icon-content { display: flex; align-items: center; font-size: 1.8em; font-weight: bold; } -@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { + +@media all and (-ms-high-contrast: none), +(-ms-high-contrast: active) { .swal2-popup.swal2-toast .swal2-icon .swal2-icon-content { font-size: 0.25em; } } + .swal2-popup.swal2-toast .swal2-icon.swal2-success .swal2-success-ring { width: 2em; height: 2em; } + .swal2-popup.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line] { top: 0.875em; width: 1.375em; } + .swal2-popup.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=left] { left: 0.3125em; } + .swal2-popup.swal2-toast .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=right] { right: 0.3125em; } + .swal2-popup.swal2-toast .swal2-actions { flex-basis: auto !important; width: auto; height: auto; margin: 0 0.3125em; } + .swal2-popup.swal2-toast .swal2-styled { margin: 0 0.3125em; padding: 0.3125em 0.625em; font-size: 1em; } + .swal2-popup.swal2-toast .swal2-styled:focus { box-shadow: 0 0 0 1px #fff, 0 0 0 3px rgba(50, 100, 150, 0.4); } + .swal2-popup.swal2-toast .swal2-success { border-color: #a5dc86; } + .swal2-popup.swal2-toast .swal2-success [class^=swal2-success-circular-line] { position: absolute; width: 1.6em; @@ -86,6 +104,7 @@ transform: rotate(45deg); border-radius: 50%; } + .swal2-popup.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=left] { top: -0.8em; left: -0.5em; @@ -93,50 +112,60 @@ transform-origin: 2em 2em; border-radius: 4em 0 0 4em; } + .swal2-popup.swal2-toast .swal2-success [class^=swal2-success-circular-line][class$=right] { top: -0.25em; left: 0.9375em; transform-origin: 0 1.5em; border-radius: 0 4em 4em 0; } + .swal2-popup.swal2-toast .swal2-success .swal2-success-ring { width: 2em; height: 2em; } + .swal2-popup.swal2-toast .swal2-success .swal2-success-fix { top: 0; left: 0.4375em; width: 0.4375em; height: 2.6875em; } + .swal2-popup.swal2-toast .swal2-success [class^=swal2-success-line] { height: 0.3125em; } + .swal2-popup.swal2-toast .swal2-success [class^=swal2-success-line][class$=tip] { top: 1.125em; left: 0.1875em; width: 0.75em; } + .swal2-popup.swal2-toast .swal2-success [class^=swal2-success-line][class$=long] { top: 0.9375em; right: 0.1875em; width: 1.375em; } + .swal2-popup.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-tip { -webkit-animation: swal2-toast-animate-success-line-tip 0.75s; - animation: swal2-toast-animate-success-line-tip 0.75s; + animation: swal2-toast-animate-success-line-tip 0.75s; } + .swal2-popup.swal2-toast .swal2-success.swal2-icon-show .swal2-success-line-long { -webkit-animation: swal2-toast-animate-success-line-long 0.75s; - animation: swal2-toast-animate-success-line-long 0.75s; + animation: swal2-toast-animate-success-line-long 0.75s; } + .swal2-popup.swal2-toast.swal2-show { -webkit-animation: swal2-toast-show 0.5s; - animation: swal2-toast-show 0.5s; + animation: swal2-toast-show 0.5s; } + .swal2-popup.swal2-toast.swal2-hide { -webkit-animation: swal2-toast-hide 0.1s forwards; - animation: swal2-toast-hide 0.1s forwards; + animation: swal2-toast-hide 0.1s forwards; } .swal2-container { @@ -155,86 +184,131 @@ transition: background-color 0.1s; -webkit-overflow-scrolling: touch; } + .swal2-container.swal2-backdrop-show { background: rgba(0, 0, 0, 0.4); } + .swal2-container.swal2-backdrop-hide { background: transparent !important; } + .swal2-container.swal2-top { align-items: flex-start; } -.swal2-container.swal2-top-start, .swal2-container.swal2-top-left { + +.swal2-container.swal2-top-start, +.swal2-container.swal2-top-left { align-items: flex-start; justify-content: flex-start; } -.swal2-container.swal2-top-end, .swal2-container.swal2-top-right { + +.swal2-container.swal2-top-end, +.swal2-container.swal2-top-right { align-items: flex-start; justify-content: flex-end; } + .swal2-container.swal2-center { align-items: center; } -.swal2-container.swal2-center-start, .swal2-container.swal2-center-left { + +.swal2-container.swal2-center-start, +.swal2-container.swal2-center-left { align-items: center; justify-content: flex-start; } -.swal2-container.swal2-center-end, .swal2-container.swal2-center-right { + +.swal2-container.swal2-center-end, +.swal2-container.swal2-center-right { align-items: center; justify-content: flex-end; } + .swal2-container.swal2-bottom { align-items: flex-end; } -.swal2-container.swal2-bottom-start, .swal2-container.swal2-bottom-left { + +.swal2-container.swal2-bottom-start, +.swal2-container.swal2-bottom-left { align-items: flex-end; justify-content: flex-start; } -.swal2-container.swal2-bottom-end, .swal2-container.swal2-bottom-right { + +.swal2-container.swal2-bottom-end, +.swal2-container.swal2-bottom-right { align-items: flex-end; justify-content: flex-end; } -.swal2-container.swal2-bottom > :first-child, .swal2-container.swal2-bottom-start > :first-child, .swal2-container.swal2-bottom-left > :first-child, .swal2-container.swal2-bottom-end > :first-child, .swal2-container.swal2-bottom-right > :first-child { + +.swal2-container.swal2-bottom> :first-child, +.swal2-container.swal2-bottom-start> :first-child, +.swal2-container.swal2-bottom-left> :first-child, +.swal2-container.swal2-bottom-end> :first-child, +.swal2-container.swal2-bottom-right> :first-child { margin-top: auto; } -.swal2-container.swal2-grow-fullscreen > .swal2-modal { + +.swal2-container.swal2-grow-fullscreen>.swal2-modal { display: flex !important; flex: 1; align-self: stretch; justify-content: center; } -.swal2-container.swal2-grow-row > .swal2-modal { + +.swal2-container.swal2-grow-row>.swal2-modal { display: flex !important; flex: 1; align-content: center; justify-content: center; } + .swal2-container.swal2-grow-column { flex: 1; flex-direction: column; } -.swal2-container.swal2-grow-column.swal2-top, .swal2-container.swal2-grow-column.swal2-center, .swal2-container.swal2-grow-column.swal2-bottom { + +.swal2-container.swal2-grow-column.swal2-top, +.swal2-container.swal2-grow-column.swal2-center, +.swal2-container.swal2-grow-column.swal2-bottom { align-items: center; } -.swal2-container.swal2-grow-column.swal2-top-start, .swal2-container.swal2-grow-column.swal2-center-start, .swal2-container.swal2-grow-column.swal2-bottom-start, .swal2-container.swal2-grow-column.swal2-top-left, .swal2-container.swal2-grow-column.swal2-center-left, .swal2-container.swal2-grow-column.swal2-bottom-left { + +.swal2-container.swal2-grow-column.swal2-top-start, +.swal2-container.swal2-grow-column.swal2-center-start, +.swal2-container.swal2-grow-column.swal2-bottom-start, +.swal2-container.swal2-grow-column.swal2-top-left, +.swal2-container.swal2-grow-column.swal2-center-left, +.swal2-container.swal2-grow-column.swal2-bottom-left { align-items: flex-start; } -.swal2-container.swal2-grow-column.swal2-top-end, .swal2-container.swal2-grow-column.swal2-center-end, .swal2-container.swal2-grow-column.swal2-bottom-end, .swal2-container.swal2-grow-column.swal2-top-right, .swal2-container.swal2-grow-column.swal2-center-right, .swal2-container.swal2-grow-column.swal2-bottom-right { + +.swal2-container.swal2-grow-column.swal2-top-end, +.swal2-container.swal2-grow-column.swal2-center-end, +.swal2-container.swal2-grow-column.swal2-bottom-end, +.swal2-container.swal2-grow-column.swal2-top-right, +.swal2-container.swal2-grow-column.swal2-center-right, +.swal2-container.swal2-grow-column.swal2-bottom-right { align-items: flex-end; } -.swal2-container.swal2-grow-column > .swal2-modal { + +.swal2-container.swal2-grow-column>.swal2-modal { display: flex !important; flex: 1; align-content: center; justify-content: center; } + .swal2-container.swal2-no-transition { transition: none !important; } -.swal2-container:not(.swal2-top):not(.swal2-top-start):not(.swal2-top-end):not(.swal2-top-left):not(.swal2-top-right):not(.swal2-center-start):not(.swal2-center-end):not(.swal2-center-left):not(.swal2-center-right):not(.swal2-bottom):not(.swal2-bottom-start):not(.swal2-bottom-end):not(.swal2-bottom-left):not(.swal2-bottom-right):not(.swal2-grow-fullscreen) > .swal2-modal { + +.swal2-container:not(.swal2-top):not(.swal2-top-start):not(.swal2-top-end):not(.swal2-top-left):not(.swal2-top-right):not(.swal2-center-start):not(.swal2-center-end):not(.swal2-center-left):not(.swal2-center-right):not(.swal2-bottom):not(.swal2-bottom-start):not(.swal2-bottom-end):not(.swal2-bottom-left):not(.swal2-bottom-right):not(.swal2-grow-fullscreen)>.swal2-modal { margin: auto; } -@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { + +@media all and (-ms-high-contrast: none), +(-ms-high-contrast: active) { .swal2-container .swal2-modal { margin: 0 !important; } @@ -255,9 +329,11 @@ font-family: inherit; font-size: 1rem; } + .swal2-popup:focus { outline: none; } + .swal2-popup.swal2-loading { overflow-y: hidden; } @@ -290,15 +366,19 @@ width: 100%; margin: 1.25em auto 0; } + .swal2-actions:not(.swal2-loading) .swal2-styled[disabled] { opacity: 0.4; } + .swal2-actions:not(.swal2-loading) .swal2-styled:hover { background-image: linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1)); } + .swal2-actions:not(.swal2-loading) .swal2-styled:active { background-image: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)); } + .swal2-actions.swal2-loading .swal2-styled.swal2-confirm { box-sizing: border-box; width: 2.5em; @@ -306,7 +386,7 @@ margin: 0.46875em; padding: 0; -webkit-animation: swal2-rotate-loading 1.5s linear 0s infinite normal; - animation: swal2-rotate-loading 1.5s linear 0s infinite normal; + animation: swal2-rotate-loading 1.5s linear 0s infinite normal; border: 0.25em solid transparent; border-radius: 100%; border-color: transparent; @@ -314,14 +394,16 @@ color: transparent; cursor: default; -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } + .swal2-actions.swal2-loading .swal2-styled.swal2-cancel { margin-right: 30px; margin-left: 30px; } + .swal2-actions.swal2-loading :not(.swal2-styled).swal2-confirm::after { content: ""; display: inline-block; @@ -329,7 +411,7 @@ height: 15px; margin-left: 5px; -webkit-animation: swal2-rotate-loading 1.5s linear 0s infinite normal; - animation: swal2-rotate-loading 1.5s linear 0s infinite normal; + animation: swal2-rotate-loading 1.5s linear 0s infinite normal; border: 3px solid #999999; border-radius: 50%; border-right-color: transparent; @@ -342,9 +424,11 @@ box-shadow: none; font-weight: 500; } + .swal2-styled:not([disabled]) { cursor: pointer; } + .swal2-styled.swal2-confirm { border: 0; border-radius: 0.25em; @@ -353,6 +437,7 @@ color: #fff; font-size: 1.0625em; } + .swal2-styled.swal2-cancel { border: 0; border-radius: 0.25em; @@ -361,10 +446,12 @@ color: #fff; font-size: 1.0625em; } + .swal2-styled:focus { outline: none; box-shadow: 0 0 0 1px #fff, 0 0 0 3px rgba(50, 100, 150, 0.4); } + .swal2-styled::-moz-focus-inner { border: 0; } @@ -423,11 +510,13 @@ line-height: 1.2; cursor: pointer; } + .swal2-close:hover { transform: none; background: transparent; color: #f27474; } + .swal2-close::-moz-focus-inner { border: 0; } @@ -467,12 +556,14 @@ color: inherit; font-size: 1.125em; } + .swal2-input.swal2-inputerror, .swal2-file.swal2-inputerror, .swal2-textarea.swal2-inputerror { border-color: #f27474 !important; box-shadow: 0 0 2px #f27474 !important; } + .swal2-input:focus, .swal2-file:focus, .swal2-textarea:focus { @@ -480,18 +571,31 @@ outline: none; box-shadow: 0 0 3px #c4e6f5; } -.swal2-input::-webkit-input-placeholder, .swal2-file::-webkit-input-placeholder, .swal2-textarea::-webkit-input-placeholder { + +.swal2-input::-webkit-input-placeholder, +.swal2-file::-webkit-input-placeholder, +.swal2-textarea::-webkit-input-placeholder { color: #cccccc; } -.swal2-input::-moz-placeholder, .swal2-file::-moz-placeholder, .swal2-textarea::-moz-placeholder { + +.swal2-input::-moz-placeholder, +.swal2-file::-moz-placeholder, +.swal2-textarea::-moz-placeholder { color: #cccccc; } -.swal2-input:-ms-input-placeholder, .swal2-file:-ms-input-placeholder, .swal2-textarea:-ms-input-placeholder { + +.swal2-input:-ms-input-placeholder, +.swal2-file:-ms-input-placeholder, +.swal2-textarea:-ms-input-placeholder { color: #cccccc; } -.swal2-input::-ms-input-placeholder, .swal2-file::-ms-input-placeholder, .swal2-textarea::-ms-input-placeholder { + +.swal2-input::-ms-input-placeholder, +.swal2-file::-ms-input-placeholder, +.swal2-textarea::-ms-input-placeholder { color: #cccccc; } + .swal2-input::placeholder, .swal2-file::placeholder, .swal2-textarea::placeholder { @@ -502,15 +606,18 @@ margin: 1em auto; background: #fff; } + .swal2-range input { width: 80%; } + .swal2-range output { width: 20%; color: inherit; font-weight: 600; text-align: center; } + .swal2-range input, .swal2-range output { height: 2.625em; @@ -523,6 +630,7 @@ height: 2.625em; padding: 0 0.75em; } + .swal2-input[type=number] { max-width: 10em; } @@ -553,11 +661,13 @@ background: #fff; color: inherit; } + .swal2-radio label, .swal2-checkbox label { margin: 0 0.6em; font-size: 1.125em; } + .swal2-radio input, .swal2-checkbox input { margin: 0 0.4em; @@ -574,6 +684,7 @@ font-size: 1em; font-weight: 300; } + .swal2-validation-message::before { content: "!"; display: inline-block; @@ -602,23 +713,27 @@ line-height: 5em; cursor: default; -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } + .swal2-icon .swal2-icon-content { display: flex; align-items: center; font-size: 3.75em; } + .swal2-icon.swal2-error { border-color: #f27474; color: #f27474; } + .swal2-icon.swal2-error .swal2-x-mark { position: relative; flex-grow: 1; } + .swal2-icon.swal2-error [class^=swal2-x-mark-line] { display: block; position: absolute; @@ -628,38 +743,47 @@ border-radius: 0.125em; background-color: #f27474; } + .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=left] { left: 1.0625em; transform: rotate(45deg); } + .swal2-icon.swal2-error [class^=swal2-x-mark-line][class$=right] { right: 1em; transform: rotate(-45deg); } + .swal2-icon.swal2-error.swal2-icon-show { -webkit-animation: swal2-animate-error-icon 0.5s; - animation: swal2-animate-error-icon 0.5s; + animation: swal2-animate-error-icon 0.5s; } + .swal2-icon.swal2-error.swal2-icon-show .swal2-x-mark { -webkit-animation: swal2-animate-error-x-mark 0.5s; - animation: swal2-animate-error-x-mark 0.5s; + animation: swal2-animate-error-x-mark 0.5s; } + .swal2-icon.swal2-warning { border-color: #facea8; color: #f8bb86; } + .swal2-icon.swal2-info { border-color: #9de0f6; color: #3fc3ee; } + .swal2-icon.swal2-question { border-color: #c9dae1; color: #87adbd; } + .swal2-icon.swal2-success { border-color: #a5dc86; color: #a5dc86; } + .swal2-icon.swal2-success [class^=swal2-success-circular-line] { position: absolute; width: 3.75em; @@ -667,6 +791,7 @@ transform: rotate(45deg); border-radius: 50%; } + .swal2-icon.swal2-success [class^=swal2-success-circular-line][class$=left] { top: -0.4375em; left: -2.0635em; @@ -674,6 +799,7 @@ transform-origin: 3.75em 3.75em; border-radius: 7.5em 0 0 7.5em; } + .swal2-icon.swal2-success [class^=swal2-success-circular-line][class$=right] { top: -0.6875em; left: 1.875em; @@ -681,6 +807,7 @@ transform-origin: 0 3.75em; border-radius: 0 7.5em 7.5em 0; } + .swal2-icon.swal2-success .swal2-success-ring { position: absolute; z-index: 2; @@ -692,6 +819,7 @@ border: 0.25em solid rgba(165, 220, 134, 0.3); border-radius: 50%; } + .swal2-icon.swal2-success .swal2-success-fix { position: absolute; z-index: 1; @@ -701,6 +829,7 @@ height: 5.625em; transform: rotate(-45deg); } + .swal2-icon.swal2-success [class^=swal2-success-line] { display: block; position: absolute; @@ -709,29 +838,34 @@ border-radius: 0.125em; background-color: #a5dc86; } + .swal2-icon.swal2-success [class^=swal2-success-line][class$=tip] { top: 2.875em; left: 0.8125em; width: 1.5625em; transform: rotate(45deg); } + .swal2-icon.swal2-success [class^=swal2-success-line][class$=long] { top: 2.375em; right: 0.5em; width: 2.9375em; transform: rotate(-45deg); } + .swal2-icon.swal2-success.swal2-icon-show .swal2-success-line-tip { -webkit-animation: swal2-animate-success-line-tip 0.75s; - animation: swal2-animate-success-line-tip 0.75s; + animation: swal2-animate-success-line-tip 0.75s; } + .swal2-icon.swal2-success.swal2-icon-show .swal2-success-line-long { -webkit-animation: swal2-animate-success-line-long 0.75s; - animation: swal2-animate-success-line-long 0.75s; + animation: swal2-animate-success-line-long 0.75s; } + .swal2-icon.swal2-success.swal2-icon-show .swal2-success-circular-line-right { -webkit-animation: swal2-rotate-success-circular-line 4.25s ease-in; - animation: swal2-rotate-success-circular-line 4.25s ease-in; + animation: swal2-rotate-success-circular-line 4.25s ease-in; } .swal2-progress-steps { @@ -741,10 +875,12 @@ background: inherit; font-weight: 600; } + .swal2-progress-steps li { display: inline-block; position: relative; } + .swal2-progress-steps .swal2-progress-step { z-index: 20; width: 2em; @@ -755,16 +891,20 @@ line-height: 2em; text-align: center; } + .swal2-progress-steps .swal2-progress-step.swal2-active-progress-step { background: #3085d6; } -.swal2-progress-steps .swal2-progress-step.swal2-active-progress-step ~ .swal2-progress-step { + +.swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step { background: #add8e6; color: #fff; } -.swal2-progress-steps .swal2-progress-step.swal2-active-progress-step ~ .swal2-progress-step-line { + +.swal2-progress-steps .swal2-progress-step.swal2-active-progress-step~.swal2-progress-step-line { background: #add8e6; } + .swal2-progress-steps .swal2-progress-step-line { z-index: 10; width: 2.5em; @@ -779,12 +919,12 @@ .swal2-show { -webkit-animation: swal2-show 0.3s; - animation: swal2-show 0.3s; + animation: swal2-show 0.3s; } .swal2-hide { -webkit-animation: swal2-hide 0.15s forwards; - animation: swal2-hide 0.15s forwards; + animation: swal2-hide 0.15s forwards; } .swal2-noanimation { @@ -803,6 +943,7 @@ right: auto; left: 0; } + .swal2-rtl .swal2-timer-progress-bar { right: 0; left: auto; @@ -812,419 +953,509 @@ .swal2-range input { width: 100% !important; } + .swal2-range output { display: none; } } -@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { + +@media all and (-ms-high-contrast: none), +(-ms-high-contrast: active) { .swal2-range input { width: 100% !important; } + .swal2-range output { display: none; } } + @-moz-document url-prefix() { .swal2-close:focus { outline: 2px solid rgba(50, 100, 150, 0.4); } } + @-webkit-keyframes swal2-toast-show { 0% { transform: translateY(-0.625em) rotateZ(2deg); } + 33% { transform: translateY(0) rotateZ(-2deg); } + 66% { transform: translateY(0.3125em) rotateZ(2deg); } + 100% { transform: translateY(0) rotateZ(0deg); } } + @keyframes swal2-toast-show { 0% { transform: translateY(-0.625em) rotateZ(2deg); } + 33% { transform: translateY(0) rotateZ(-2deg); } + 66% { transform: translateY(0.3125em) rotateZ(2deg); } + 100% { transform: translateY(0) rotateZ(0deg); } } + @-webkit-keyframes swal2-toast-hide { 100% { transform: rotateZ(1deg); opacity: 0; } } + @keyframes swal2-toast-hide { 100% { transform: rotateZ(1deg); opacity: 0; } } + @-webkit-keyframes swal2-toast-animate-success-line-tip { 0% { top: 0.5625em; left: 0.0625em; width: 0; } + 54% { top: 0.125em; left: 0.125em; width: 0; } + 70% { top: 0.625em; left: -0.25em; width: 1.625em; } + 84% { top: 1.0625em; left: 0.75em; width: 0.5em; } + 100% { top: 1.125em; left: 0.1875em; width: 0.75em; } } + @keyframes swal2-toast-animate-success-line-tip { 0% { top: 0.5625em; left: 0.0625em; width: 0; } + 54% { top: 0.125em; left: 0.125em; width: 0; } + 70% { top: 0.625em; left: -0.25em; width: 1.625em; } + 84% { top: 1.0625em; left: 0.75em; width: 0.5em; } + 100% { top: 1.125em; left: 0.1875em; width: 0.75em; } } + @-webkit-keyframes swal2-toast-animate-success-line-long { 0% { top: 1.625em; right: 1.375em; width: 0; } + 65% { top: 1.25em; right: 0.9375em; width: 0; } + 84% { top: 0.9375em; right: 0; width: 1.125em; } + 100% { top: 0.9375em; right: 0.1875em; width: 1.375em; } } + @keyframes swal2-toast-animate-success-line-long { 0% { top: 1.625em; right: 1.375em; width: 0; } + 65% { top: 1.25em; right: 0.9375em; width: 0; } + 84% { top: 0.9375em; right: 0; width: 1.125em; } + 100% { top: 0.9375em; right: 0.1875em; width: 1.375em; } } + @-webkit-keyframes swal2-show { 0% { transform: scale(0.7); } + 45% { transform: scale(1.05); } + 80% { transform: scale(0.95); } + 100% { transform: scale(1); } } + @keyframes swal2-show { 0% { transform: scale(0.7); } + 45% { transform: scale(1.05); } + 80% { transform: scale(0.95); } + 100% { transform: scale(1); } } + @-webkit-keyframes swal2-hide { 0% { transform: scale(1); opacity: 1; } + 100% { transform: scale(0.5); opacity: 0; } } + @keyframes swal2-hide { 0% { transform: scale(1); opacity: 1; } + 100% { transform: scale(0.5); opacity: 0; } } + @-webkit-keyframes swal2-animate-success-line-tip { 0% { top: 1.1875em; left: 0.0625em; width: 0; } + 54% { top: 1.0625em; left: 0.125em; width: 0; } + 70% { top: 2.1875em; left: -0.375em; width: 3.125em; } + 84% { top: 3em; left: 1.3125em; width: 1.0625em; } + 100% { top: 2.8125em; left: 0.8125em; width: 1.5625em; } } + @keyframes swal2-animate-success-line-tip { 0% { top: 1.1875em; left: 0.0625em; width: 0; } + 54% { top: 1.0625em; left: 0.125em; width: 0; } + 70% { top: 2.1875em; left: -0.375em; width: 3.125em; } + 84% { top: 3em; left: 1.3125em; width: 1.0625em; } + 100% { top: 2.8125em; left: 0.8125em; width: 1.5625em; } } + @-webkit-keyframes swal2-animate-success-line-long { 0% { top: 3.375em; right: 2.875em; width: 0; } + 65% { top: 3.375em; right: 2.875em; width: 0; } + 84% { top: 2.1875em; right: 0; width: 3.4375em; } + 100% { top: 2.375em; right: 0.5em; width: 2.9375em; } } + @keyframes swal2-animate-success-line-long { 0% { top: 3.375em; right: 2.875em; width: 0; } + 65% { top: 3.375em; right: 2.875em; width: 0; } + 84% { top: 2.1875em; right: 0; width: 3.4375em; } + 100% { top: 2.375em; right: 0.5em; width: 2.9375em; } } + @-webkit-keyframes swal2-rotate-success-circular-line { 0% { transform: rotate(-45deg); } + 5% { transform: rotate(-45deg); } + 12% { transform: rotate(-405deg); } + 100% { transform: rotate(-405deg); } } + @keyframes swal2-rotate-success-circular-line { 0% { transform: rotate(-45deg); } + 5% { transform: rotate(-45deg); } + 12% { transform: rotate(-405deg); } + 100% { transform: rotate(-405deg); } } + @-webkit-keyframes swal2-animate-error-x-mark { 0% { margin-top: 1.625em; transform: scale(0.4); opacity: 0; } + 50% { margin-top: 1.625em; transform: scale(0.4); opacity: 0; } + 80% { margin-top: -0.375em; transform: scale(1.15); } + 100% { margin-top: 0; transform: scale(1); opacity: 1; } } + @keyframes swal2-animate-error-x-mark { 0% { margin-top: 1.625em; transform: scale(0.4); opacity: 0; } + 50% { margin-top: 1.625em; transform: scale(0.4); opacity: 0; } + 80% { margin-top: -0.375em; transform: scale(1.15); } + 100% { margin-top: 0; transform: scale(1); opacity: 1; } } + @-webkit-keyframes swal2-animate-error-icon { 0% { transform: rotateX(100deg); opacity: 0; } + 100% { transform: rotateX(0deg); opacity: 1; } } + @keyframes swal2-animate-error-icon { 0% { transform: rotateX(100deg); opacity: 0; } + 100% { transform: rotateX(0deg); opacity: 1; } } + @-webkit-keyframes swal2-rotate-loading { 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } + @keyframes swal2-rotate-loading { 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } + body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown) { overflow: hidden; } + body.swal2-height-auto { height: auto !important; } + body.swal2-no-backdrop .swal2-container { top: auto; right: auto; @@ -1233,64 +1464,85 @@ body.swal2-no-backdrop .swal2-container { max-width: calc(100% - 0.625em * 2); background-color: transparent !important; } -body.swal2-no-backdrop .swal2-container > .swal2-modal { + +body.swal2-no-backdrop .swal2-container>.swal2-modal { box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); } + body.swal2-no-backdrop .swal2-container.swal2-top { top: 0; left: 50%; transform: translateX(-50%); } -body.swal2-no-backdrop .swal2-container.swal2-top-start, body.swal2-no-backdrop .swal2-container.swal2-top-left { + +body.swal2-no-backdrop .swal2-container.swal2-top-start, +body.swal2-no-backdrop .swal2-container.swal2-top-left { top: 0; left: 0; } -body.swal2-no-backdrop .swal2-container.swal2-top-end, body.swal2-no-backdrop .swal2-container.swal2-top-right { + +body.swal2-no-backdrop .swal2-container.swal2-top-end, +body.swal2-no-backdrop .swal2-container.swal2-top-right { top: 0; right: 0; } + body.swal2-no-backdrop .swal2-container.swal2-center { top: 50%; left: 50%; transform: translate(-50%, -50%); } -body.swal2-no-backdrop .swal2-container.swal2-center-start, body.swal2-no-backdrop .swal2-container.swal2-center-left { + +body.swal2-no-backdrop .swal2-container.swal2-center-start, +body.swal2-no-backdrop .swal2-container.swal2-center-left { top: 50%; left: 0; transform: translateY(-50%); } -body.swal2-no-backdrop .swal2-container.swal2-center-end, body.swal2-no-backdrop .swal2-container.swal2-center-right { + +body.swal2-no-backdrop .swal2-container.swal2-center-end, +body.swal2-no-backdrop .swal2-container.swal2-center-right { top: 50%; right: 0; transform: translateY(-50%); } + body.swal2-no-backdrop .swal2-container.swal2-bottom { bottom: 0; left: 50%; transform: translateX(-50%); } -body.swal2-no-backdrop .swal2-container.swal2-bottom-start, body.swal2-no-backdrop .swal2-container.swal2-bottom-left { + +body.swal2-no-backdrop .swal2-container.swal2-bottom-start, +body.swal2-no-backdrop .swal2-container.swal2-bottom-left { bottom: 0; left: 0; } -body.swal2-no-backdrop .swal2-container.swal2-bottom-end, body.swal2-no-backdrop .swal2-container.swal2-bottom-right { + +body.swal2-no-backdrop .swal2-container.swal2-bottom-end, +body.swal2-no-backdrop .swal2-container.swal2-bottom-right { right: 0; bottom: 0; } + @media print { body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown) { overflow-y: scroll !important; } - body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown) > [aria-hidden=true] { + + body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown)>[aria-hidden=true] { display: none; } + body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown) .swal2-container { position: static !important; } } + body.swal2-toast-shown .swal2-container { background-color: transparent; } + body.swal2-toast-shown .swal2-container.swal2-top { top: 0; right: auto; @@ -1298,25 +1550,32 @@ body.swal2-toast-shown .swal2-container.swal2-top { left: 50%; transform: translateX(-50%); } -body.swal2-toast-shown .swal2-container.swal2-top-end, body.swal2-toast-shown .swal2-container.swal2-top-right { + +body.swal2-toast-shown .swal2-container.swal2-top-end, +body.swal2-toast-shown .swal2-container.swal2-top-right { top: 0; right: 0; bottom: auto; left: auto; } -body.swal2-toast-shown .swal2-container.swal2-top-start, body.swal2-toast-shown .swal2-container.swal2-top-left { + +body.swal2-toast-shown .swal2-container.swal2-top-start, +body.swal2-toast-shown .swal2-container.swal2-top-left { top: 0; right: auto; bottom: auto; left: 0; } -body.swal2-toast-shown .swal2-container.swal2-center-start, body.swal2-toast-shown .swal2-container.swal2-center-left { + +body.swal2-toast-shown .swal2-container.swal2-center-start, +body.swal2-toast-shown .swal2-container.swal2-center-left { top: 50%; right: auto; bottom: auto; left: 0; transform: translateY(-50%); } + body.swal2-toast-shown .swal2-container.swal2-center { top: 50%; right: auto; @@ -1324,19 +1583,24 @@ body.swal2-toast-shown .swal2-container.swal2-center { left: 50%; transform: translate(-50%, -50%); } -body.swal2-toast-shown .swal2-container.swal2-center-end, body.swal2-toast-shown .swal2-container.swal2-center-right { + +body.swal2-toast-shown .swal2-container.swal2-center-end, +body.swal2-toast-shown .swal2-container.swal2-center-right { top: 50%; right: 0; bottom: auto; left: auto; transform: translateY(-50%); } -body.swal2-toast-shown .swal2-container.swal2-bottom-start, body.swal2-toast-shown .swal2-container.swal2-bottom-left { + +body.swal2-toast-shown .swal2-container.swal2-bottom-start, +body.swal2-toast-shown .swal2-container.swal2-bottom-left { top: auto; right: auto; bottom: 0; left: 0; } + body.swal2-toast-shown .swal2-container.swal2-bottom { top: auto; right: auto; @@ -1344,30 +1608,37 @@ body.swal2-toast-shown .swal2-container.swal2-bottom { left: 50%; transform: translateX(-50%); } -body.swal2-toast-shown .swal2-container.swal2-bottom-end, body.swal2-toast-shown .swal2-container.swal2-bottom-right { + +body.swal2-toast-shown .swal2-container.swal2-bottom-end, +body.swal2-toast-shown .swal2-container.swal2-bottom-right { top: auto; right: 0; bottom: 0; left: auto; } + body.swal2-toast-column .swal2-toast { flex-direction: column; align-items: stretch; } + body.swal2-toast-column .swal2-toast .swal2-actions { flex: 1; align-self: stretch; height: 2.2em; margin-top: 0.3125em; } + body.swal2-toast-column .swal2-toast .swal2-loading { justify-content: center; } + body.swal2-toast-column .swal2-toast .swal2-input { height: 2em; margin: 0.3125em auto; font-size: 1em; } + body.swal2-toast-column .swal2-toast .swal2-validation-message { font-size: 1em; } \ No newline at end of file diff --git a/EdgeAdmin/web/views/@default/@layout.css b/EdgeAdmin/web/views/@default/@layout.css index 2109f21..66b2498 100644 --- a/EdgeAdmin/web/views/@default/@layout.css +++ b/EdgeAdmin/web/views/@default/@layout.css @@ -7,20 +7,24 @@ overflow-x: hidden; border-right: 1px #ddd solid; } + .left-box .menu { width: 95% !important; } + .left-box .menu .item { line-height: 1.2; position: relative; padding-left: 1em !important; } + .left-box .menu .item .icon { position: absolute; top: 50%; left: 0; margin-top: -0.4em !important; } + .left-box .menu .item.separator { border-bottom: 1px #eee solid !important; padding-top: 0; @@ -28,9 +32,11 @@ margin-top: 0 !important; margin-bottom: 0 !important; } + .left-box .menu .item.on span { border-bottom: 1px #666 dashed; } + .left-box .menu .item.off span var { font-style: normal; background: #db2828; @@ -40,33 +46,42 @@ border-radius: 2px; margin-left: 1em; } + .left-box .menu .item.active { background: rgba(230, 230, 230, 0.35) !important; border-radius: 3px; } + .left-box .menu .header { border-bottom: 1px #ddd solid; padding-left: 0 !important; padding-bottom: 1em !important; } + .left-box::-webkit-scrollbar { width: 4px; } + .left-box.disabled { opacity: 0.1; } + .left-box.tiny { top: 10em; } + .left-box.without-tabbar { top: 3em; } + .left-box.with-menu { top: 8.7em; } + .left-box.without-menu { top: 6em; } + .right-box { position: fixed; top: 7em; @@ -77,47 +92,60 @@ padding-bottom: 2em; overflow-y: auto; } + .right-box h4:first-child { margin-top: 1em; } -.right-box > .comment:first-child { + +.right-box>.comment:first-child { margin-top: 0.5em; } + @media screen and (max-width: 512px) { .right-box { left: 13em; padding-right: 1em; } } + body.expanded .right-box { left: 10em; } + .right-box.tiny { top: 10em; left: 26.5em; } + .right-box::-webkit-scrollbar { width: 4px; } + .right-box.without-tabbar { top: 3em; } + .right-box.with-menu { top: 8.6em; } + .right-box.without-menu { top: 6em; } + .main.without-footer .left-box { bottom: 0.2em; } + .narrow-scrollbar::-webkit-scrollbar { width: 4px; } + .grid.counter-chart { margin-top: 1em !important; margin-left: 0.4em !important; } + .grid.counter-chart .column { margin-bottom: 1em; font-size: 0.85em; @@ -126,203 +154,256 @@ body.expanded .right-box { border: 1px rgba(0, 0, 0, 0.1) solid; border-right: 0; } + .grid.counter-chart .column div.value { margin-top: 1.5em; font-weight: normal; } + .grid.counter-chart .column div.value span { font-size: 1.5em; margin-right: 0.2em; } + .grid.counter-chart .column.with-border { border-right: 1px rgba(0, 0, 0, 0.1) solid; } + .grid.counter-chart h4 { color: grey; position: relative; font-size: 1em; text-align: left; } + .grid.counter-chart h4 a { position: absolute; right: 0.1em; font-size: 1.26em; display: none; } + .grid.counter-chart .column:hover { background: rgba(0, 0, 0, 0.03) !important; } + .grid.counter-chart .column:hover a { display: inline; } + .grid.chart-grid { margin-top: 1em !important; margin-left: 0.4em !important; } + .grid.chart-grid .column { margin-bottom: 1em; border: 1px rgba(0, 0, 0, 0.1) solid; border-right: 0; } + .grid.chart-grid .column .menu { margin-top: -0.6em !important; margin-bottom: -0.6em !important; } + .grid.chart-grid .column h4 span { font-size: 0.8em; color: grey; } + .grid.chart-grid .column.with-border { border-right: 1px rgba(0, 0, 0, 0.1) solid; } + /** 通用 **/ * { scrollbar-color: rgba(0, 0, 0, 0.2) transparent; scrollbar-width: thin; } + .clear { clear: both; } + .hidden { display: none; } + pre { white-space: pre-wrap; } + a.disabled, a.disabled:hover, a.disabled:active, span.disabled { color: #ccc !important; } + a.enabled, span.enabled, span.green { color: #21ba45; } + span.grey, label.grey, p.grey { color: grey !important; } + p.grey { margin-top: 0.8em; } + span.red, pre.red { color: #db2828; } + span.blue { color: #4183c4; } + span.orange { color: #ff851b; } + pre:not(.CodeMirror-line) { font-family: Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif !important; } + tbody { background: transparent; } + .table-box { margin-top: 1em; overflow-x: auto; } + .table-box::-webkit-scrollbar { height: 6px; } + .table.width30 { width: 30em !important; } + .table.width35 { width: 35em !important; } + .table.width40 { width: 40em !important; } + .table.width1024 { width: 1024px !important; } + .table th, .table td { font-size: 0.9em !important; } + .table tr.active td { background: rgba(0, 0, 0, 0.01) !important; } + p.comment, div.comment { color: #959da6; padding-top: 0.4em; font-size: 1em; } + p.comment em, div.comment em { font-style: italic !important; } + .truncate { white-space: nowrap; -ms-text-overflow: ellipsis; overflow: hidden; text-overflow: ellipsis; } + div.margin, p.margin { margin-top: 1em; } + .opacity-mask { opacity: 0.3; } + /** 操作按钮容器 **/ .op.one { width: 4em; } + .op.two { width: 7.4em; } + .op.three { width: 9em; } + .op.four { width: 12em; } + /** 主菜单 **/ .main-menu { width: 8em !important; } + .main-menu .ui.menu { width: 100% !important; } + .main-menu .ui.menu .item.separator { border-top: 1px rgba(0, 0, 0, 0.2) solid; height: 1px; min-height: 0; padding: 0; } + @media screen and (max-width: 512px) { .main-menu { width: auto !important; } + .main-menu .ui.menu { width: 3.6em !important; } + .main-menu .ui.menu .item.separator { display: none; } + .main-menu .ui.menu .item { padding-top: 2em; padding-bottom: 2.4em; } } + .main-menu .ui.labeled.icon.menu .item { font-size: 0.9em; } + .main-menu .ui.menu { padding-bottom: 3em; } + .main-menu .ui.menu .item .subtitle { display: none; } + .main-menu .ui.menu .item.expend .subtitle { display: block; font-size: 10px; @@ -330,20 +411,24 @@ p.margin { margin-top: 0.5em; color: grey; } + @media screen and (max-width: 512px) { .main-menu .ui.menu .item.expend .subtitle { display: none; } } + .main-menu .ui.menu .sub-items .item { padding-left: 2.8em !important; padding-right: 0.4em !important; } + .main-menu .ui.menu .sub-items .item .icon { position: absolute; left: 1.1em; top: 0.93em; } + .main-menu .ui.menu .sub-items .item .label { margin-left: 0; margin-right: 0; @@ -351,53 +436,67 @@ p.margin { padding-right: 0.4em; min-width: 2em; } + @media screen and (max-width: 512px) { .main-menu .ui.menu .sub-items .item { padding-left: 1em !important; } } + .main-menu .ui.menu .sub-items .item.active { background-color: #2185d0 !important; } + /** 扩展UI **/ .field.text { padding: 0.5em; } + .form .fields:not(.inline) .field { margin-bottom: 0.5em !important; } + .form .fields:not(.inline) .field .button { min-width: 5em; } + /** body **/ @keyframes blink { from { opacity: 0.1; } + to { opacity: 0.8; } } + @keyframes rotation { from { transform: rotate(0); } + to { transform: rotate(360deg); } } + body .ui.menu .item .blink { animation: blink 1s infinite; } + body .ui.menu .item:not(:hover) span.rotate { animation: rotation 3s infinite; } + body.expanded .main-menu { display: none; } + body.expanded .main { left: 1em; } + /** 布局相关 */ .top-nav { border-radius: 0 !important; @@ -407,6 +506,7 @@ body.expanded .main { overflow-x: auto; border: 0 !important; } + .top-nav img.avatar { width: 1.6em !important; height: 1.6em !important; @@ -415,44 +515,57 @@ body.expanded .main { border-radius: 0.9em; margin-right: 0.5em !important; } + .top-nav em { font-style: normal; font-size: 0.9em; padding-left: 0.2em; } + .top-nav .item .hover-span span { display: none; } + .top-nav .item:hover .hover-span span { display: inline; } + .top-nav .item.red { color: red !important; } + .top-nav.theme1 { background: #14539A !important; } + .top-nav.theme2 { background: #276AC6 !important; } + .top-nav.theme3 { background: #007D9C !important; } + .top-nav.theme4 { background: #A12568 !important; } + .top-nav.theme5 { background: #1C7947 !important; } + .top-nav.theme6 { background: #1D365D !important; } + .top-nav.theme7 { background: black !important; } + .top-nav::-webkit-scrollbar { height: 2px; } + /** 顶部菜单 **/ .top-secondary-menu { position: fixed; @@ -462,23 +575,28 @@ body.expanded .main { z-index: 100; background: white; } + .top-secondary-menu .menu { margin-top: 0 !important; margin-bottom: 0 !important; border-radius: 0 !important; } + .top-secondary-menu .menu var { font-style: normal; } + .top-secondary-menu .divider { margin-top: 0 !important; margin-bottom: 0 !important; } + @media screen and (max-width: 512px) { .top-secondary-menu { left: 4em; } } + /** 右侧主操作区 **/ .main { position: absolute; @@ -488,84 +606,107 @@ body.expanded .main { padding-right: 0.2em; right: 1em; } + @media screen and (max-width: 512px) { .main { left: 4em; } + .main .main-box { display: block; } } + .main.without-menu { left: 9em; } + .main.without-secondary-menu { top: 2.9em; } + @media screen and (max-width: 512px) { .main.without-menu { left: 4em; } } + .main table td.title { width: 10em; } + .main table td.middle-title { width: 14em; } + .main table td { vertical-align: top; } + .main table td.color-border { border-left: 1px #276ac6 solid !important; } + .main table td.vertical-top { vertical-align: top; } + .main table td.vertical-middle { vertical-align: middle; } + .main table td[colspan="2"] a { font-weight: normal; } + .main table td em { font-weight: normal; font-style: normal; font-size: 0.9em; } + .main table td em.grey { color: grey; } + .main h3 { font-weight: normal; margin-top: 1em !important; position: relative; } + .main h3 span { font-size: 0.8em; } + .main h3 span.label { color: #6435c9; } + .main h3 a { margin-left: 1em; font-size: 14px !important; right: 1em; } + .main h4 { font-weight: normal; } + .main form h4 { margin-top: 0.6em; } + .main td span.small { font-size: 0.8em; } + .main .button.mini { font-size: 0.8em; padding: 0.2em; margin-left: 1em; } + .main-menu { position: fixed; /**top: 1.05em;**/ @@ -574,89 +715,114 @@ body.expanded .main { overflow-y: auto; z-index: 10; } + .main-menu .menu { border: 0 !important; box-shadow: none !important; } + .main-menu.theme1 { background: #14539A !important; } + .main-menu.theme1 .menu { background: #14539A !important; } + .main-menu.theme2 { background: #276AC6 !important; } + .main-menu.theme2 .menu { background: #276AC6 !important; } + .main-menu.theme3 { background: #007D9C !important; } + .main-menu.theme3 .menu { background: #007D9C !important; } + .main-menu.theme4 { background: #A12568 !important; } + .main-menu.theme4 .menu { background: #A12568 !important; } + .main-menu.theme5 { background: #1C7947 !important; } + .main-menu.theme5 .menu { background: #1C7947 !important; } + .main-menu.theme6 { background: #1D365D !important; } + .main-menu.theme6 .menu { background: #1D365D !important; } + .main-menu.theme7 { background: black !important; } + .main-menu.theme7 .menu { background: black !important; } + .main-menu::-webkit-scrollbar { width: 4px; } + .main .tab-menu { margin-top: 0.3em !important; margin-bottom: 0 !important; overflow-x: auto; overflow-y: hidden; } + .main .tab-menu .item { padding: 0 1em !important; } + .main .tab-menu .item var { font-style: normal; } + .main .tab-menu .item span { font-size: 0.8em; padding-left: 0.3em; } + .main .tab-menu .item .icon { margin-left: 0.6em; } + .main .tab-menu .item.active.title { font-weight: normal !important; margin-right: 1em !important; border-radius: 0 !important; } + .main .tab-menu .item:hover { background: #f8f8f9 !important; border-width: 1px; } + .main .tab-menu .item.active:not(.title) { font-weight: normal !important; border: none; border-radius: 0 !important; color: #2185d0 !important; } + .main .tab-menu .item.active:not(.title) .bottom-indicator { border-bottom: 1px #2185d0 solid; position: absolute; @@ -664,6 +830,7 @@ body.expanded .main { right: 0; bottom: 1px; } + .main .tab-menu .item.active:not(.title).icon .bottom-indicator { border-bottom: 1px #2185d0 solid; position: absolute; @@ -671,12 +838,15 @@ body.expanded .main { right: 0.6em; bottom: 1px; } + .main .tab-menu .item.active.blue { font-weight: bold !important; } + .main .tab-menu::-webkit-scrollbar { height: 4px; } + .main .go-top-btn { position: fixed; right: 2.6em; @@ -687,14 +857,17 @@ body.expanded .main { z-index: 999999; background: white; } + /** 右侧文本子菜单 **/ .text.menu { overflow-x: auto; } + .text.menu::-webkit-scrollbar { width: 4px; height: 4px; } + /** 脚部相关样式 **/ #footer { position: fixed; @@ -706,27 +879,34 @@ body.expanded .main { z-index: 10; overflow-x: auto; } + #footer::-webkit-scrollbar { height: 2px; } + #footer a { font-size: 0.9em; } + #footer a form { display: none; } + #footer a:hover span, #footer a:active span { display: none; } + #footer a:hover form, #footer a:active form { display: block; } + #footer form input { padding: 0; margin: 0; } + #footer-outer-box { z-index: 999999; position: fixed; @@ -736,6 +916,7 @@ body.expanded .main { background: rgba(0, 0, 0, 0.8); bottom: 2.6em; } + #footer-outer-box .qrcode { width: 20em; position: absolute; @@ -744,95 +925,119 @@ body.expanded .main { margin-top: -14em; margin-left: -10em; } + #footer-outer-box .qrcode img { width: 100%; } + #footer-outer-box .qrcode a { position: absolute; right: 0.5em; top: 0.5em; } + @media screen and (max-width: 512px) { #footer-outer-box .qrcode { margin-left: 0; left: 3.5em; } } + /** Vue **/ [v-cloak] { display: none !important; } + /** auto complete **/ .autocomplete-box .menu { background: #eee !important; } + .autocomplete-box .menu::-webkit-scrollbar { width: 4px; } + .autocomplete-box .menu .item { border-top: none !important; } + select.auto-width { width: auto !important; } + /** column **/ @media screen and (max-width: 512px) { .column:not(.one) { width: 100% !important; } } + label[for] { cursor: pointer !important; } + label.blue { color: #2185d0 !important; } + /** Menu **/ .first-menu .menu.text { margin-top: 0 !important; margin-bottom: 0 !important; } + .first-menu .divider { margin-top: 0 !important; margin-bottom: 0 !important; } + .second-menu .menu.text { margin-top: 0 !important; margin-bottom: 0 !important; } + .second-menu .menu.text em { font-style: normal; } + .second-menu .divider { margin-top: 0 !important; } + .menu a { outline: none; } + /** var **/ span.olive, var.olive { color: #b5cc18 !important; } + span.dash { border-bottom: 1px dashed grey; } + span.hover:hover { background: #eee; } + var.normal { font-style: normal; } + /** checkbox **/ .checkbox label a, .checkbox label { font-size: 0.9em !important; } + /** page **/ .page { margin-top: 1em; border-left: 1px solid #ddd; } + .page a { display: inline-block; background: #fafafa; @@ -843,86 +1048,109 @@ var.normal { border: 1px solid #ddd; border-left: 0; } + .page a.active { background: #2185d0 !important; color: white; } + .page a:hover { background: #eee; } + .page select { padding-top: 0.3em !important; padding-bottom: 0.3em !important; } + /** popup **/ .swal2-html-container { overflow-x: hidden; } + .swal2-close, .swal2-close:focus { border: 0; } + .swal2-confirm:focus, .swal2-cancel:focus { border: 3px #ddd solid !important; } + .swal2-confirm, .swal2-cancel { border: 3px #fff solid !important; } + .swal2-cancel { margin-left: 2em !important; } + /** 排序 **/ .sortable-ghost { background: #ddd !important; opacity: 0.1; } + .sortable-drag { opacity: 1; } + .icon.handle { cursor: pointer; } + .label.port-label { margin-top: 0.4em !important; margin-bottom: 0.4em !important; display: block; line-height: 1.5; } + .label { word-break: break-all; } + td .label.small { margin-bottom: 0.2em !important; } + td { word-break: break-all; } + .source-code-box .CodeMirror { border: 1px solid #eee; height: auto !important; } + .source-code-box .CodeMirror-vscrollbar { width: 6px; border-radius: 3px !important; } + .source-code-box .CodeMirror-vscrollbar::-webkit-scrollbar-thumb { border-radius: 2px; } + .scroll-box { overflow-y: auto; } + .scroll-box::-webkit-scrollbar { width: 4px; } + input.error { border: 1px #e0b4b4 solid !important; } + textarea.wide-code { font-family: Menlo, Monaco, "Courier New", monospace !important; line-height: 1.6 !important; } + .combo-box .menu { max-height: 17em; overflow-y: auto; @@ -931,7 +1159,66 @@ textarea.wide-code { border-top: 0; z-index: 100; } + .combo-box .menu::-webkit-scrollbar { width: 4px; } -/*# sourceMappingURL=@layout.css.map */ \ No newline at end of file + +/*# sourceMappingURL=@layout.css.map */ +/* Override Primary Button Color for WAF Platform */ +.ui.primary.button, +.ui.primary.buttons .button { + background-color: #0f2c54 !important; + color: #ffffff !important; +} + +.ui.primary.button:hover, +.ui.primary.buttons .button:hover { + background-color: #0a1f3a !important; +} + +.ui.primary.button:focus, +.ui.primary.buttons .button:focus { + background-color: #08192e !important; +} + +.ui.primary.button:active, +.ui.primary.buttons .button:active, +.ui.primary.active.button { + background-color: #050d18 !important; +} + +.text-primary, +.blue { + color: #0f2c54 !important; +} + +/* Override Semantic UI Default Blue */ +.ui.blue.button, +.ui.blue.buttons .button { + background-color: #0f2c54 !important; + color: #ffffff !important; +} + +.ui.blue.button:hover, +.ui.blue.buttons .button:hover { + background-color: #0a1f3a !important; +} + +.ui.basic.blue.button, +.ui.basic.blue.buttons .button { + box-shadow: 0 0 0 1px #0f2c54 inset !important; + color: #0f2c54 !important; +} + +.ui.basic.blue.button:hover, +.ui.basic.blue.buttons .button:hover { + background: transparent !important; + box-shadow: 0 0 0 1px #0a1f3a inset !important; + color: #0a1f3a !important; +} + +.ui.menu .active.item { + border-color: #2185d0 !important; + color: #0f2c54 !important; +} \ No newline at end of file diff --git a/EdgeAdmin/web/views/@default/@layout.html b/EdgeAdmin/web/views/@default/@layout.html index 708bdc6..722a93c 100644 --- a/EdgeAdmin/web/views/@default/@layout.html +++ b/EdgeAdmin/web/views/@default/@layout.html @@ -1,15 +1,16 @@ + {$ htmlEncode .teaTitle} - + {$if eq .teaFaviconFileId 0} - + {$else} - + {$end} - + {$TEA.SEMANTIC} {$TEA.VUE} @@ -20,7 +21,7 @@ window.BRAND_DOCS_SITE = {$ jsonEncode .brandConfig.docsSite}; window.BRAND_DOCS_PREFIX = {$ jsonEncode .brandConfig.docsPathPrefix}; window.BRAND_PRODUCT_NAME = {$ jsonEncode .brandConfig.productName}; - + // 确保 teaName 和 teaVersion 在 Vue 初始化前可用 if (typeof window.TEA === "undefined") { window.TEA = {}; @@ -36,122 +37,152 @@ - - - - + + + + - + - + + -
- -