带阿里标识的版本

This commit is contained in:
robin
2026-02-28 18:55:33 +08:00
parent 150799f41d
commit 5d0b7c7e91
477 changed files with 10813 additions and 4044 deletions

View File

@@ -1,7 +1,7 @@
package teaconst
const (
Version = "1.4.7" //1.3.8.2
Version = "1.4.8" //1.3.8.2
ProductName = "Edge User"
ProcessName = "edge-user"

View File

@@ -420,6 +420,34 @@ func (this *RPCClient) NSPlanRPC() pb.NSPlanServiceClient {
return pb.NewNSPlanServiceClient(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) HTTPDNSSandboxRPC() pb.HTTPDNSSandboxServiceClient {
return pb.NewHTTPDNSSandboxServiceClient(this.pickConn())
}
func (this *RPCClient) ServerBandwidthStatRPC() pb.ServerBandwidthStatServiceClient {
return pb.NewServerBandwidthStatServiceClient(this.pickConn())
}

View File

@@ -0,0 +1,123 @@
package httpdns
import (
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"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", "绔彛鍙蜂笉鑳藉皬浜?")
}
if intPort > 65535 {
this.FailField("address", "绔彛鍙蜂笉鑳藉ぇ浜?5535")
}
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", "绔彛鍙蜂笉鑳藉皬浜?")
}
if port1 > 65535 {
this.FailField("address", "绔彛鍙蜂笉鑳藉ぇ浜?5535")
}
if port2 < 1 {
this.FailField("address", "绔彛鍙蜂笉鑳藉皬浜?")
}
if port2 > 65535 {
this.FailField("address", "绔彛鍙蜂笉鑳藉ぇ浜?5535")
}
if port1 > port2 {
port1, port2 = port2, port1
}
return types.String(port1) + "-" + types.String(port2), port1, port2
}

View File

@@ -0,0 +1,26 @@
package apps
import (
"strconv"
"github.com/TeaOSLab/EdgeUser/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))
}

View File

@@ -0,0 +1,120 @@
package apps
import (
"strconv"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/default/httpdns/httpdnsutils"
"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 + "&section=basic",
"isActive": section == "basic",
},
{
"name": "认证与密钥",
"url": "/httpdns/apps/app/settings?appId=" + appIDStr + "&section=auth",
"isActive": section == "auth",
},
}
clusterResp, err := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.UserContext(), &pb.FindAllHTTPDNSClustersRequest{})
if err != nil {
this.ErrorPage(err)
return
}
clusterDomainMap := map[int64]string{}
for _, cluster := range clusterResp.GetClusters() {
clusterDomainMap[cluster.GetId()] = cluster.GetServiceDomain()
}
primaryClusterId := app.GetInt64("primaryClusterId")
backupClusterId := app.GetInt64("backupClusterId")
settings := maps.Map{
"appId": app.GetString("appId"),
"appStatus": app.GetBool("isOn"),
"primaryClusterId": primaryClusterId,
"backupClusterId": backupClusterId,
"primaryServiceDomain": clusterDomainMap[primaryClusterId],
"backupServiceDomain": clusterDomainMap[backupClusterId],
"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.Show()
}
func (this *AppSettingsAction) RunPost(params struct {
AppId int64
AppStatus bool
Must *actions.Must
CSRF *actionutils.CSRF
}) {
params.Must.Field("appId", params.AppId).Gt(0, "请选择应用")
appResp, err := this.RPC().HTTPDNSAppRPC().FindHTTPDNSApp(this.UserContext(), &pb.FindHTTPDNSAppRequest{
AppDbId: params.AppId,
})
if err != nil {
this.ErrorPage(err)
return
}
if appResp.GetApp() == nil {
this.Fail("找不到对应的应用")
return
}
_, err = this.RPC().HTTPDNSAppRPC().UpdateHTTPDNSApp(this.UserContext(), &pb.UpdateHTTPDNSAppRequest{
AppDbId: params.AppId,
Name: appResp.GetApp().GetName(),
PrimaryClusterId: appResp.GetApp().GetPrimaryClusterId(),
BackupClusterId: appResp.GetApp().GetBackupClusterId(),
IsOn: params.AppStatus,
UserId: appResp.GetApp().GetUserId(),
})
if err != nil {
this.ErrorPage(err)
return
}
this.Success()
}

View File

@@ -0,0 +1,29 @@
package apps
import (
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"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.UserContext(), &pb.ResetHTTPDNSAppSignSecretRequest{
AppDbId: params.AppId,
})
if err != nil {
this.ErrorPage(err)
return
}
this.Success()
}

View File

@@ -0,0 +1,31 @@
package apps
import (
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"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.UserContext(), &pb.UpdateHTTPDNSAppSignEnabledRequest{
AppDbId: params.AppId,
SignEnabled: params.IsOn == 1,
})
if err != nil {
this.ErrorPage(err)
return
}
this.Success()
}

View File

@@ -0,0 +1,47 @@
package apps
import (
"strconv"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/actions"
)
type CreateAction struct {
actionutils.ParentAction
}
func (this *CreateAction) Init() {
this.Nav("", "", "create")
}
func (this *CreateAction) RunGet(params struct{}) {
this.Show()
}
func (this *CreateAction) RunPost(params struct {
Name string
Must *actions.Must
CSRF *actionutils.CSRF
}) {
params.Must.Field("name", params.Name).Require("请输入应用名称")
createResp, err := this.RPC().HTTPDNSAppRPC().CreateHTTPDNSApp(this.UserContext(), &pb.CreateHTTPDNSAppRequest{
Name: params.Name,
AppId: "app" + strconv.FormatInt(time.Now().UnixNano()%1_000_000_000_000, 36),
PrimaryClusterId: 0,
BackupClusterId: 0,
IsOn: true,
SignEnabled: true,
})
if err != nil {
this.ErrorPage(err)
return
}
this.Data["appId"] = createResp.GetAppDbId()
this.Success()
}

View File

@@ -0,0 +1,55 @@
package apps
import (
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeUser/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()
}

View File

@@ -0,0 +1,263 @@
package apps
import (
"encoding/json"
"fmt"
"strings"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"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": 30,
"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)
}

View File

@@ -0,0 +1,21 @@
package apps
import "github.com/TeaOSLab/EdgeUser/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()
}

View File

@@ -0,0 +1,22 @@
package apps
import "github.com/TeaOSLab/EdgeUser/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()
}

View File

@@ -0,0 +1,48 @@
package apps
import (
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeUser/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()
}

View File

@@ -0,0 +1,39 @@
package apps
import (
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeUser/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.Data["keyword"] = ""
this.Show()
}

View File

@@ -0,0 +1,44 @@
package apps
import (
"github.com/TeaOSLab/EdgeUser/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()
}

View File

@@ -0,0 +1,20 @@
package apps
import "github.com/TeaOSLab/EdgeUser/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()
}

View File

@@ -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, ", ")
}

View File

@@ -0,0 +1,28 @@
package apps
import (
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeUser/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.Show()
}

View File

@@ -0,0 +1,35 @@
package apps
import (
"github.com/TeaOSLab/EdgeUser/internal/web/helpers"
"github.com/iwind/TeaGo"
)
func init() {
TeaGo.BeforeStart(func(server *TeaGo.Server) {
server.
Helper(helpers.NewUserMustAuth("")).
Data("teaMenu", "httpdns").
Data("teaSubMenu", "app").
Prefix("/httpdns/apps").
Get("", new(IndexAction)).
Get("/app", new(AppAction)).
Get("/sdk", new(SdkAction)).
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()
})
}

View File

@@ -0,0 +1,298 @@
package apps
import (
"errors"
"strconv"
"strings"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
timeutil "github.com/iwind/TeaGo/utils/time"
)
func listAppMaps(parent *actionutils.ParentAction, keyword string) ([]maps.Map, error) {
resp, err := parent.RPC().HTTPDNSAppRPC().ListHTTPDNSApps(parent.UserContext(), &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.UserContext(), &pb.ListHTTPDNSDomainsWithAppIdRequest{
AppDbId: app.GetId(),
})
if err != nil {
return nil, err
}
result = append(result, appPBToMap(app, int64(len(domainResp.GetDomains()))))
}
return result, nil
}
func findAppMap(parent *actionutils.ParentAction, appDbId int64) (maps.Map, error) {
if appDbId > 0 {
resp, err := parent.RPC().HTTPDNSAppRPC().FindHTTPDNSApp(parent.UserContext(), &pb.FindHTTPDNSAppRequest{
AppDbId: appDbId,
})
if err != nil {
return nil, err
}
if resp.GetApp() != nil {
domainResp, err := parent.RPC().HTTPDNSDomainRPC().ListHTTPDNSDomainsWithAppId(parent.UserContext(), &pb.ListHTTPDNSDomainsWithAppIdRequest{
AppDbId: appDbId,
})
if err != nil {
return nil, err
}
return appPBToMap(resp.GetApp(), int64(len(domainResp.GetDomains()))), nil
}
return nil, errors.New("应用不存在或无权限访问")
}
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, primaryClusterId int64, backupClusterId int64) (int64, error) {
newAppId := "app" + strconv.FormatInt(time.Now().UnixNano()%1_000_000_000_000, 36)
resp, err := parent.RPC().HTTPDNSAppRPC().CreateHTTPDNSApp(parent.UserContext(), &pb.CreateHTTPDNSAppRequest{
Name: strings.TrimSpace(name),
AppId: newAppId,
PrimaryClusterId: primaryClusterId,
BackupClusterId: backupClusterId,
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.UserContext(), &pb.DeleteHTTPDNSAppRequest{
AppDbId: appDbId,
})
return err
}
func updateAppSettings(parent *actionutils.ParentAction, appDbId int64, name string, primaryClusterId int64, backupClusterId int64, isOn bool, userId int64) error {
_, err := parent.RPC().HTTPDNSAppRPC().UpdateHTTPDNSApp(parent.UserContext(), &pb.UpdateHTTPDNSAppRequest{
AppDbId: appDbId,
Name: strings.TrimSpace(name),
PrimaryClusterId: primaryClusterId,
BackupClusterId: backupClusterId,
IsOn: isOn,
UserId: userId,
})
return err
}
func updateAppSignEnabled(parent *actionutils.ParentAction, appDbId int64, signEnabled bool) error {
_, err := parent.RPC().HTTPDNSAppRPC().UpdateHTTPDNSAppSignEnabled(parent.UserContext(), &pb.UpdateHTTPDNSAppSignEnabledRequest{
AppDbId: appDbId,
SignEnabled: signEnabled,
})
return err
}
func resetAppSignSecret(parent *actionutils.ParentAction, appDbId int64) (*pb.ResetHTTPDNSAppSignSecretResponse, error) {
return parent.RPC().HTTPDNSAppRPC().ResetHTTPDNSAppSignSecret(parent.UserContext(), &pb.ResetHTTPDNSAppSignSecretRequest{
AppDbId: appDbId,
})
}
func listDomainMaps(parent *actionutils.ParentAction, appDbId int64, keyword string) ([]maps.Map, error) {
resp, err := parent.RPC().HTTPDNSDomainRPC().ListHTTPDNSDomainsWithAppId(parent.UserContext(), &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.UserContext(), &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.UserContext(), &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.UserContext(), &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.UserContext(), &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.UserContext(), &pb.UpdateHTTPDNSCustomRuleRequest{
Rule: rule,
})
return err
}
func deleteCustomRule(parent *actionutils.ParentAction, ruleId int64) error {
_, err := parent.RPC().HTTPDNSRuleRPC().DeleteHTTPDNSCustomRule(parent.UserContext(), &pb.DeleteHTTPDNSCustomRuleRequest{
RuleId: ruleId,
})
return err
}
func toggleCustomRule(parent *actionutils.ParentAction, ruleId int64, isOn bool) error {
_, err := parent.RPC().HTTPDNSRuleRPC().UpdateHTTPDNSCustomRuleStatus(parent.UserContext(), &pb.UpdateHTTPDNSCustomRuleStatusRequest{
RuleId: ruleId,
IsOn: isOn,
})
return err
}
func appPBToMap(app *pb.HTTPDNSApp, domainCount int64) maps.Map {
signSecret := app.GetSignSecret()
return maps.Map{
"id": app.GetId(),
"name": app.GetName(),
"appId": app.GetAppId(),
"clusterId": app.GetPrimaryClusterId(),
"primaryClusterId": app.GetPrimaryClusterId(),
"backupClusterId": app.GetBackupClusterId(),
"userId": app.GetUserId(),
"isOn": app.GetIsOn(),
"domainCount": domainCount,
"sniPolicyText": "闅愬尶 SNI",
"signEnabled": app.GetSignEnabled(),
"signSecretPlain": signSecret,
"signSecretMasked": maskSecret(signSecret),
"signSecretUpdated": formatDateTime(app.GetSignUpdatedAt()),
}
}
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)
}

View File

@@ -0,0 +1,30 @@
package apps
import (
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeUser/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()
}

View File

@@ -0,0 +1,67 @@
package apps
import (
"net/url"
"strings"
"github.com/TeaOSLab/EdgeUser/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
}
t := strings.ToLower(strings.TrimSpace(params.Type))
if t == "doc" {
docPath := findUploadedSDKDocPath(platform, params.Version)
if len(docPath) == 0 {
this.Data["exists"] = false
this.Data["message"] = "Documentation is unavailable, please contact admin"
this.Success()
return
}
downloadURL := "/httpdns/apps/sdk/doc?platform=" + url.QueryEscape(platform)
if len(strings.TrimSpace(params.Version)) > 0 {
downloadURL += "&version=" + url.QueryEscape(strings.TrimSpace(params.Version))
}
this.Data["exists"] = true
this.Data["url"] = downloadURL
this.Success()
return
}
archivePath := findSDKArchivePath(filename, params.Version)
if len(archivePath) == 0 {
this.Data["exists"] = false
this.Data["message"] = "SDK package is unavailable, please contact admin"
this.Success()
return
}
downloadURL := "/httpdns/apps/sdk/download?platform=" + url.QueryEscape(platform)
if len(strings.TrimSpace(params.Version)) > 0 {
downloadURL += "&version=" + url.QueryEscape(strings.TrimSpace(params.Version))
}
downloadURL += "&raw=1"
this.Data["exists"] = true
this.Data["url"] = downloadURL
this.Success()
}

View File

@@ -0,0 +1,65 @@
package apps
import (
"os"
"path/filepath"
"strings"
"github.com/TeaOSLab/EdgeUser/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, _, readmeRelativePath, _, err := resolveSDKPlatform(params.Platform)
if err != nil {
this.Data["exists"] = false
this.Data["message"] = err.Error()
this.Success()
return
}
var data []byte
uploadedDocPath := findUploadedSDKDocPath(platform, params.Version)
if len(uploadedDocPath) > 0 {
data, err = os.ReadFile(uploadedDocPath)
}
sdkRoot, sdkRootErr := findSDKRoot()
if len(data) == 0 && sdkRootErr == nil {
readmePath := filepath.Join(sdkRoot, readmeRelativePath)
data, err = os.ReadFile(readmePath)
}
if len(data) == 0 {
localDocPath := findLocalSDKDocPath(platform)
if len(localDocPath) > 0 {
data, err = os.ReadFile(localDocPath)
}
}
if len(data) == 0 || err != nil {
this.Data["exists"] = false
this.Data["message"] = "SDK documentation is unavailable for current platform, please contact admin"
this.Success()
return
}
downloadName := filepath.Base(uploadedDocPath)
if len(downloadName) == 0 || downloadName == "." || downloadName == string(filepath.Separator) {
downloadName = "httpdns-sdk-" + strings.ToLower(platform) + ".md"
}
this.AddHeader("Content-Type", "text/markdown; charset=utf-8")
this.AddHeader("Content-Disposition", "attachment; filename=\""+downloadName+"\";")
_, _ = this.ResponseWriter.Write(data)
}

View File

@@ -0,0 +1,65 @@
package apps
import (
"io"
"os"
"path/filepath"
"github.com/TeaOSLab/EdgeUser/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 archive is unavailable for current platform, please contact admin: " + filename
this.Success()
return
}
fp, err := os.Open(archivePath)
if err != nil {
this.Data["exists"] = false
this.Data["message"] = "failed to open SDK archive: " + 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=\""+downloadName+"\";")
}
this.AddHeader("X-Accel-Buffering", "no")
_, _ = io.Copy(this.ResponseWriter, fp)
}

View File

@@ -0,0 +1,218 @@
package apps
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/iwind/TeaGo/Tea"
)
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 findFirstExistingDir(paths []string) string {
for _, path := range paths {
stat, err := os.Stat(path)
if err == nil && stat.IsDir() {
return path
}
}
return ""
}
func findFirstExistingFile(paths []string) string {
for _, path := range paths {
stat, err := os.Stat(path)
if err == nil && !stat.IsDir() && stat.Size() > 0 {
return path
}
}
return ""
}
func findNewestExistingFile(paths []string) string {
type fileInfo struct {
path string
modTime time.Time
}
result := fileInfo{}
for _, path := range paths {
stat, err := os.Stat(path)
if err != nil || stat.IsDir() {
continue
}
if stat.Size() <= 0 {
continue
}
if len(result.path) == 0 || stat.ModTime().After(result.modTime) || (stat.ModTime().Equal(result.modTime) && path > result.path) {
result.path = path
result.modTime = stat.ModTime()
}
}
return result.path
}
func findSDKRoot() (string, error) {
candidates := []string{
filepath.Clean(Tea.Root + "/EdgeHttpDNS/sdk"),
filepath.Clean(Tea.Root + "/edge-httpdns/sdk"),
filepath.Clean(Tea.Root + "/edge-httpdns/edge-httpdns/sdk"),
filepath.Clean(Tea.Root + "/../EdgeHttpDNS/sdk"),
filepath.Clean(Tea.Root + "/../../EdgeHttpDNS/sdk"),
filepath.Clean(Tea.Root + "/../edge-httpdns/sdk"),
filepath.Clean(Tea.Root + "/../../edge-httpdns/sdk"),
}
dir := findFirstExistingDir(candidates)
if len(dir) > 0 {
return dir, nil
}
return "", errors.New("SDK files are not found on current server")
}
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/aliyun_httpdns", "flutter/aliyun_httpdns/README.md", "httpdns-sdk-flutter.zip", nil
default:
return "", "", "", "", errors.New("invalid platform, expected one of: android, ios, flutter")
}
}
func findSDKArchivePath(downloadFilename string, version string) string {
searchDirs := sdkUploadSearchDirs()
normalizedVersion := strings.TrimSpace(version)
base := strings.TrimSuffix(downloadFilename, ".zip")
if len(normalizedVersion) > 0 {
versionFiles := []string{}
for _, dir := range searchDirs {
versionFiles = append(versionFiles, filepath.Join(dir, base+"-v"+normalizedVersion+".zip"))
}
if path := findFirstExistingFile(versionFiles); len(path) > 0 {
return path
}
return ""
}
patternName := base + "-v*.zip"
matches := []string{}
for _, dir := range searchDirs {
found, _ := filepath.Glob(filepath.Join(dir, patternName))
for _, file := range found {
stat, err := os.Stat(file)
if err == nil && !stat.IsDir() {
matches = append(matches, file)
}
}
}
if len(matches) > 0 {
return findNewestExistingFile(matches)
}
exactFiles := []string{}
for _, dir := range searchDirs {
exactFiles = append(exactFiles, filepath.Join(dir, downloadFilename))
}
if path := findFirstExistingFile(exactFiles); len(path) > 0 {
return path
}
return ""
}
func findUploadedSDKDocPath(platform string, version string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
if len(platform) == 0 {
return ""
}
searchDirs := sdkUploadSearchDirs()
normalizedVersion := strings.TrimSpace(version)
if len(normalizedVersion) > 0 {
exactVersion := []string{}
for _, dir := range searchDirs {
exactVersion = append(exactVersion, filepath.Join(dir, "httpdns-sdk-"+platform+"-v"+normalizedVersion+".md"))
}
if file := findFirstExistingFile(exactVersion); len(file) > 0 {
return file
}
return ""
}
matches := []string{}
for _, dir := range searchDirs {
pattern := filepath.Join(dir, "httpdns-sdk-"+platform+"-v*.md")
found, _ := filepath.Glob(pattern)
matches = append(matches, found...)
}
if len(matches) > 0 {
sort.Strings(matches)
return findNewestExistingFile(matches)
}
exact := []string{}
for _, dir := range searchDirs {
exact = append(exact, filepath.Join(dir, "httpdns-sdk-"+platform+".md"))
}
return findFirstExistingFile(exact)
}
func findLocalSDKDocPath(platform string) string {
filename := strings.ToLower(strings.TrimSpace(platform)) + ".md"
candidates := []string{
filepath.Clean(Tea.Root + "/edge-admin/web/views/@default/httpdns/apps/docs/" + filename),
filepath.Clean(Tea.Root + "/EdgeUser/web/views/@default/httpdns/apps/docs/" + filename),
filepath.Clean(Tea.Root + "/EdgeAdmin/web/views/@default/httpdns/apps/docs/" + filename),
}
return findFirstExistingFile(candidates)
}

View File

@@ -0,0 +1,57 @@
package httpdnsutils
import (
"strconv"
"github.com/TeaOSLab/EdgeUser/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": "\u5e94\u7528\u7ba1\u7406",
"url": "/httpdns/apps",
"isActive": tab == "app",
},
{
"name": "\u8bbf\u95ee\u65e5\u5fd7",
"url": "/httpdns/resolveLogs",
"isActive": tab == "resolveLogs",
},
{
"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)
}

View File

@@ -0,0 +1,11 @@
package httpdns
import "github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) RunGet(params struct{}) {
this.RedirectURL("/httpdns/apps")
}

View File

@@ -0,0 +1,18 @@
package httpdns
import (
"github.com/TeaOSLab/EdgeUser/internal/web/helpers"
"github.com/iwind/TeaGo"
)
func init() {
TeaGo.BeforeStart(func(server *TeaGo.Server) {
server.
Helper(helpers.NewUserMustAuth("")).
Data("teaMenu", "httpdns").
Data("teaSubMenu", "app").
Prefix("/httpdns").
Get("", new(IndexAction)).
EndAll()
})
}

View File

@@ -0,0 +1,123 @@
package resolveLogs
import (
"strings"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/default/httpdns/httpdnsutils"
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
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"] = ""
}
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.UserContext(), &pb.FindAllHTTPDNSClustersRequest{})
if err != nil {
this.ErrorPage(err)
return
}
clusters := make([]map[string]interface{}, 0, len(clusterResp.GetClusters()))
clusterDomainMap := map[int64]string{}
for _, cluster := range clusterResp.GetClusters() {
serviceDomain := strings.TrimSpace(cluster.GetServiceDomain())
displayName := serviceDomain
if len(displayName) == 0 {
displayName = cluster.GetName()
}
clusters = append(clusters, map[string]interface{}{
"id": cluster.GetId(),
"name": cluster.GetName(),
"serviceDomain": serviceDomain,
"displayName": displayName,
})
clusterDomainMap[cluster.GetId()] = serviceDomain
}
this.Data["clusters"] = clusters
logResp, err := this.RPC().HTTPDNSAccessLogRPC().ListHTTPDNSAccessLogs(this.UserContext(), &pb.ListHTTPDNSAccessLogsRequest{
Day: "",
ClusterId: params.ClusterId,
NodeId: 0,
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(),
"serviceDomain": func() string {
serviceDomain := strings.TrimSpace(clusterDomainMap[item.GetClusterId()])
if len(serviceDomain) > 0 {
return serviceDomain
}
if len(strings.TrimSpace(item.GetClusterName())) > 0 {
return item.GetClusterName()
}
return "-"
}(),
"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()
}

View File

@@ -0,0 +1,18 @@
package resolveLogs
import (
"github.com/TeaOSLab/EdgeUser/internal/web/helpers"
"github.com/iwind/TeaGo"
)
func init() {
TeaGo.BeforeStart(func(server *TeaGo.Server) {
server.
Helper(helpers.NewUserMustAuth("")).
Data("teaMenu", "httpdns").
Data("teaSubMenu", "resolveLogs").
Prefix("/httpdns/resolveLogs").
Get("", new(IndexAction)).
EndAll()
})
}

View File

@@ -0,0 +1,74 @@
package sandbox
import (
"strings"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/default/httpdns/httpdnsutils"
"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.UserContext(), &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())
displayName := serviceDomain
if len(displayName) == 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.UserContext(), &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.UserContext(), &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())
}
apps = append(apps, maps.Map{
"id": app.GetId(),
"name": app.GetName(),
"appId": app.GetAppId(),
"clusterId": app.GetPrimaryClusterId(),
"primaryClusterId": app.GetPrimaryClusterId(),
"backupClusterId": app.GetBackupClusterId(),
"domains": domains,
})
}
this.Data["apps"] = apps
this.Show()
}

View File

@@ -0,0 +1,19 @@
package sandbox
import (
"github.com/TeaOSLab/EdgeUser/internal/web/helpers"
"github.com/iwind/TeaGo"
)
func init() {
TeaGo.BeforeStart(func(server *TeaGo.Server) {
server.
Helper(helpers.NewUserMustAuth("")).
Data("teaMenu", "httpdns").
Data("teaSubMenu", "sandbox").
Prefix("/httpdns/sandbox").
Get("", new(IndexAction)).
Post("/test", new(TestAction)).
EndAll()
})
}

View File

@@ -0,0 +1,154 @@
package sandbox
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/url"
"strconv"
"strings"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeUser/internal/web/actions/default/login/loginutils"
"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.UserContext(), &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 := ""
if params.ClusterId > 0 {
clusterResp, findErr := this.RPC().HTTPDNSClusterRPC().FindAllHTTPDNSClusters(this.UserContext(), &pb.FindAllHTTPDNSClustersRequest{})
if findErr == nil {
for _, cluster := range clusterResp.GetClusters() {
if cluster.GetId() == params.ClusterId {
clusterDomain = strings.TrimSpace(cluster.GetServiceDomain())
break
}
}
}
}
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 + "/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.UserContext(), &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))
}

View File

@@ -426,6 +426,31 @@ func (this *userMustAuth) modules(userId int64, isVerified bool, isIdentified bo
},**/
},
},
{
"code": "httpdns",
"name": "HTTPDNS",
"icon": "shield alternate",
"isOn": registerConfig != nil &&
registerConfig.HTTPDNSIsOn &&
lists.ContainsString(featureCodes, userconfigs.UserFeatureCodeHTTPDNS),
"subItems": []maps.Map{
{
"name": "应用管理",
"code": "app",
"url": "/httpdns/apps",
},
{
"name": "访问日志",
"code": "resolveLogs",
"url": "/httpdns/resolveLogs",
},
{
"name": "解析测试",
"code": "sandbox",
"url": "/httpdns/sandbox",
},
},
},
{
"code": "finance",
"name": "财务管理",

View File

@@ -106,6 +106,12 @@ import (
_ "github.com/TeaOSLab/EdgeUser/internal/web/actions/default/ns"
_ "github.com/TeaOSLab/EdgeUser/internal/web/actions/default/ns/routes"
// httpdns
_ "github.com/TeaOSLab/EdgeUser/internal/web/actions/default/httpdns"
_ "github.com/TeaOSLab/EdgeUser/internal/web/actions/default/httpdns/apps"
_ "github.com/TeaOSLab/EdgeUser/internal/web/actions/default/httpdns/resolveLogs"
_ "github.com/TeaOSLab/EdgeUser/internal/web/actions/default/httpdns/sandbox"
// api
_ "github.com/TeaOSLab/EdgeUser/internal/web/actions/default/api"

View File

@@ -18,10 +18,10 @@
{$echo "header"}
<!-- 品牌配置 -->
<script type="text/javascript">
window.BRAND_OFFICIAL_SITE = { $ jsonEncode .brandConfig.officialSite };
window.BRAND_DOCS_SITE = { $ jsonEncode .brandConfig.docsSite };
window.BRAND_DOCS_PREFIX = { $ jsonEncode .brandConfig.docsPathPrefix };
window.BRAND_PRODUCT_NAME = { $ jsonEncode .brandConfig.productName };
window.BRAND_OFFICIAL_SITE = {$ jsonEncode .brandConfig.officialSite};
window.BRAND_DOCS_SITE = {$ jsonEncode .brandConfig.docsSite};
window.BRAND_DOCS_PREFIX = {$ jsonEncode .brandConfig.docsPathPrefix};
window.BRAND_PRODUCT_NAME = {$ jsonEncode .brandConfig.productName};
</script>
<script type="text/javascript" src="/js/config/brand.js"></script>
<script type="text/javascript" src="/_/@default/@layout.js"></script>
@@ -80,8 +80,8 @@
class="disabled">消息(0)</span></span>
</a>
<a href="/settings/profile" class="item">
<i class="icon user" v-if="teaUserAvatar.length == 0"></i>
<img class="avatar" alt="" :src="teaUserAvatar" v-if="teaUserAvatar.length > 0" />
<i class="icon user" v-if="(teaUserAvatar || '').length == 0"></i>
<img class="avatar" alt="" :src="teaUserAvatar" v-if="(teaUserAvatar || '').length > 0" />
<span class="hover-span"><span class="disabled">{{teaUsername}}</span></span>
</a>
<a href="/docs" class="item" :class="{active: teaMenu == 'docs'}"><i class="icon file"></i><span
@@ -109,9 +109,9 @@
<!-- 模块 -->
<div v-for="module in teaModules">
<a class="item" :href="Tea.url(module.code)"
:class="{active:teaMenu == module.code && teaSubMenu.length == 0, separator:module.code.length == 0}"
:style="(teaMenu == module.code && teaSubMenu.length == 0) ? 'background: rgba(230, 230, 230, 0.45) !important;' : ''">
<span v-if="module.code.length > 0">
:class="{active:teaMenu == module.code && (teaSubMenu || '').length == 0, separator:(module.code || '').length == 0}"
:style="(teaMenu == module.code && (teaSubMenu || '').length == 0) ? 'background: rgba(230, 230, 230, 0.45) !important;' : ''">
<span v-if="(module.code || '').length > 0">
<i class="window restore outline icon" v-if="module.icon == null"></i>
<i class="ui icon" v-if="module.icon != null" :class="module.icon"></i>
<span>{{module.name}}</span>
@@ -131,14 +131,14 @@
<!-- 右侧主操作栏 -->
<div class="main"
:class="{'without-menu':teaSubMenus.menus == null || teaSubMenus.menus.length == 0 || (teaSubMenus.menus.length == 1 && teaSubMenus.menus[0].alwaysActive), 'without-secondary-menu':teaSubMenus.alwaysMenu == null || teaSubMenus.alwaysMenu.items.length <= 1, 'without-footer':!teaShowPageFooter}"
:class="{'without-menu':teaSubMenus == null || teaSubMenus.menus == null || teaSubMenus.menus.length == 0 || (teaSubMenus.menus.length == 1 && teaSubMenus.menus[0].alwaysActive), 'without-secondary-menu':teaSubMenus == null || teaSubMenus.alwaysMenu == null || teaSubMenus.alwaysMenu.items == null || teaSubMenus.alwaysMenu.items.length <= 1, 'without-footer':!teaShowPageFooter}"
v-cloak="">
<!-- 操作菜单 -->
<div class="ui top menu tabular tab-menu small" v-if="teaTabbar.length > 0">
<div class="ui top menu tabular tab-menu small" v-if="teaTabbar != null && teaTabbar.length > 0">
<a class="item" v-for="item in teaTabbar" :class="{'active':item.active,right:item.right}"
:href="item.url">
<var>{{item.name}}<span v-if="item.subName.length > 0">({{item.subName}})</span><i
class="icon small" :class="item.icon" v-if="item.icon != null && item.icon.length > 0"></i>
<var>{{item.name}}<span v-if="(item.subName || '').length > 0">({{item.subName}})</span><i
class="icon small" :class="item.icon" v-if="item.icon != null && (item.icon || '').length > 0"></i>
</var>
</a>
</div>
@@ -149,11 +149,11 @@
<!-- 底部 -->
<div id="footer" class="ui menu inverted light-blue borderless small"
v-if="teaShowPageFooter && teaPageFooterHTML.length == 0" v-cloak>
v-if="teaShowPageFooter && (teaPageFooterHTML || '').length == 0" v-cloak>
<a class="item" title="点击进入检查版本更新页面">{{teaName}} v{{teaVersion}}</a>
</div>
<div id="footer" class="ui menu inverted light-blue borderless small"
v-if="teaShowPageFooter && teaPageFooterHTML.length > 0" v-html="teaPageFooterHTML" v-cloak> </div>
v-if="teaShowPageFooter && (teaPageFooterHTML || '').length > 0" v-html="teaPageFooterHTML" v-cloak> </div>
</div>
{$echo "footer"}

View File

@@ -0,0 +1,4 @@
<first-menu>
<menu-item href="/httpdns/apps" code="index">应用列表</menu-item>
<a href="/httpdns/apps/create" class="item">[添加应用]</a>
</first-menu>

View File

@@ -0,0 +1,134 @@
{$layout}
{$template "menu"}
{$template "/left_menu_with_menu"}
<style>
.httpdns-mini-action {
display: inline-block;
font-size: 12px;
color: #6b7280;
margin-left: .55em;
line-height: 1.6;
}
.httpdns-mini-action:hover {
color: #1e70bf;
}
.httpdns-mini-action .icon {
margin-right: 0 !important;
font-size: .92em !important;
}
.httpdns-note.comment {
color: #8f9aa6 !important;
font-size: 12px;
margin-top: .45em !important;
}
.httpdns-auth-table td.title {
width: 260px !important;
min-width: 260px !important;
white-space: nowrap;
word-break: keep-all;
}
.httpdns-secret-line {
display: flex;
align-items: center;
gap: .35em;
}
.httpdns-mini-icon {
color: #8c96a3 !important;
font-size: 12px;
line-height: 1;
}
.httpdns-mini-icon:hover {
color: #5e6c7b !important;
}
.httpdns-state-on {
color: #21ba45;
font-weight: 600;
}
.httpdns-state-off {
color: #8f9aa6;
font-weight: 600;
}
</style>
<div class="right-box with-menu">
<form method="post" class="ui form" data-tea-action="$" data-tea-success="success">
<csrf-token></csrf-token>
<input type="hidden" name="appId" :value="app.id" />
<table class="ui table selectable definition" v-show="activeSection == 'basic'">
<tr>
<td class="title">应用启用</td>
<td>
<checkbox name="appStatus" value="1" v-model="settings.appStatus"></checkbox>
</td>
</tr>
<tr>
<td class="title">SNI 防护配置</td>
<td>
<span class="green">隐匿 SNI</span>
<p class="comment httpdns-note">当前默认采用隐匿 SNI 策略,避免在 TLS 握手阶段暴露业务域名。</p>
</td>
</tr>
</table>
<table class="ui table selectable definition httpdns-auth-table" v-show="activeSection == 'auth'">
<tr>
<td class="title">App ID</td>
<td>
<code>{{settings.appId}}</code>
<a href="" class="httpdns-mini-icon" title="复制 App ID" @click.prevent="copySecret(settings.appId, 'App ID')"><i class="copy outline icon"></i></a>
</td>
</tr>
<tr>
<td class="title">主服务域名</td>
<td>
<code v-if="settings.primaryServiceDomain && settings.primaryServiceDomain.length > 0">{{settings.primaryServiceDomain}}</code>
<span class="grey" v-else>未配置</span>
<a v-if="settings.primaryServiceDomain && settings.primaryServiceDomain.length > 0" href="" class="httpdns-mini-icon" title="复制主服务域名" @click.prevent="copySecret(settings.primaryServiceDomain, '主服务域名')"><i class="copy outline icon"></i></a>
</td>
</tr>
<tr>
<td class="title">备用服务域名</td>
<td>
<code v-if="settings.backupServiceDomain && settings.backupServiceDomain.length > 0">{{settings.backupServiceDomain}}</code>
<span class="grey" v-else>未配置</span>
<a v-if="settings.backupServiceDomain && settings.backupServiceDomain.length > 0" href="" class="httpdns-mini-icon" title="复制备用服务域名" @click.prevent="copySecret(settings.backupServiceDomain, '备用服务域名')"><i class="copy outline icon"></i></a>
</td>
</tr>
<tr>
<td class="title">请求验签</td>
<td>
<span :class="settings.signEnabled ? 'httpdns-state-on' : 'httpdns-state-off'">{{settings.signEnabled ? "已开启" : "已关闭"}}</span>
<a href="" class="ui mini button basic" style="margin-left: .8em;" @click.prevent="toggleSignEnabled">{{settings.signEnabled ? "关闭请求验签" : "开启请求验签"}}</a>
<p class="comment httpdns-note">打开后,服务端会对请求进行签名校验。</p>
</td>
</tr>
<tr>
<td class="title">加签 Secret</td>
<td>
<div class="httpdns-secret-line">
<code>{{signSecretVisible ? settings.signSecretPlain : settings.signSecretMasked}}</code>
<a href="" class="httpdns-mini-icon" @click.prevent="signSecretVisible = !signSecretVisible" :title="signSecretVisible ? '隐藏明文' : '查看明文'"><i class="icon" :class="signSecretVisible ? 'eye slash' : 'eye'"></i></a>
<a href="" class="httpdns-mini-icon" title="复制加签 Secret" @click.prevent="copySecret(settings.signSecretPlain, '加签 Secret')"><i class="copy outline icon"></i></a>
<a href="" class="httpdns-mini-icon" title="重置加签 Secret" @click.prevent="resetSignSecret"><i class="redo icon"></i></a>
</div>
<p class="comment httpdns-note">最近更新:{{settings.signSecretUpdatedAt}}</p>
<p class="comment httpdns-note" v-if="!settings.signEnabled">请求验签已关闭,当前不使用加签 Secret。</p>
<p class="comment httpdns-note">用于生成鉴权接口的安全密钥。</p>
</td>
</tr>
</table>
<submit-btn></submit-btn>
</form>
</div>

View File

@@ -0,0 +1,95 @@
Tea.context(function () {
this.activeSection = this.activeSection || "basic";
this.success = NotifyReloadSuccess("保存成功");
this.signSecretVisible = false;
this.toggleSignEnabled = function () {
let that = this;
let targetIsOn = !this.settings.signEnabled;
if (targetIsOn) {
teaweb.confirm("html:开启后,服务端会对解析请求进行签名鉴权,<span class='red'>未签名、签名无效或过期的请求都会解析失败</span>,确认开启吗?", function () {
that.$post("/httpdns/apps/app/settings/toggleSignEnabled")
.params({
appId: that.app.id,
isOn: 1
})
.success(function () {
that.settings.signEnabled = true;
teaweb.success("请求验签已开启");
});
});
return;
}
teaweb.confirm("html:关闭后,服务端不会对解析请求进行签名鉴权,可能<span class='red'>存在被刷风险</span>,确认关闭吗?", function () {
that.$post("/httpdns/apps/app/settings/toggleSignEnabled")
.params({
appId: that.app.id,
isOn: 0
})
.success(function () {
that.settings.signEnabled = false;
teaweb.success("请求验签已关闭");
});
});
};
this.copySecret = function (text, name) {
if (typeof text != "string" || text.length == 0) {
teaweb.warn("没有可复制的内容");
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
teaweb.success(name + "已复制");
}).catch(function () {
this.copyByTextarea(text, name);
}.bind(this));
return;
}
this.copyByTextarea(text, name);
};
this.copyByTextarea = function (text, name) {
let input = document.createElement("textarea");
input.value = text;
input.setAttribute("readonly", "readonly");
input.style.position = "fixed";
input.style.left = "-10000px";
input.style.top = "-10000px";
document.body.appendChild(input);
input.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch (e) {
ok = false;
}
document.body.removeChild(input);
if (ok) {
teaweb.success(name + "已复制");
} else {
teaweb.warn("复制失败,请手动复制");
}
};
this.resetSignSecret = function () {
let that = this;
teaweb.confirm("确定要重置加签 Secret 吗?", function () {
that.$post("/httpdns/apps/app/settings/resetSignSecret")
.params({
appId: that.app.id
})
.success(function () {
teaweb.success("加签 Secret 已重置", function () {
teaweb.reload();
});
});
});
};
});

View File

@@ -0,0 +1,16 @@
{$layout}
{$template "menu"}
<form method="post" class="ui form" data-tea-action="$" data-tea-success="success">
<csrf-token></csrf-token>
<table class="ui table definition selectable">
<tr>
<td class="title">应用名称 *</td>
<td>
<input type="text" name="name" maxlength="64" ref="focus" />
<p class="comment">为该 HTTPDNS 应用设置一个便于识别的名称。</p>
</td>
</tr>
</table>
<submit-btn></submit-btn>
</form>

Binary file not shown.

View File

@@ -0,0 +1,55 @@
{$layout}
<style>
.httpdns-col-ttl {
width: 72px;
white-space: nowrap;
}
.httpdns-col-actions {
width: 130px;
white-space: nowrap;
}
</style>
<second-menu>
<a class="item" :href="'/httpdns/apps/domains?appId=' + app.id">{{app.name}}</a>
<span class="item disabled" style="padding-left: 0; padding-right: 0">&raquo;</span>
<a class="item" :href="'/httpdns/apps/domains?appId=' + app.id">域名列表</a>
<span class="item disabled" style="padding-left: 0; padding-right: 0">&raquo;</span>
<div class="item"><strong>{{domain.name}}</strong></div>
<a href="" class="item" @click.prevent="createRecord" :class="{disabled: !domain || !domain.id}">创建规则</a>
</second-menu>
<table class="ui table selectable celled" v-if="records.length > 0" style="margin-top: .8em;">
<thead>
<tr>
<th>线路</th>
<th>规则名称</th>
<th>解析记录</th>
<th class="httpdns-col-ttl">TTL</th>
<th class="width10">状态</th>
<th class="httpdns-col-actions">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="record in records">
<td>{{record.lineText}}</td>
<td>{{record.ruleName}}</td>
<td>{{record.recordValueText}}</td>
<td class="httpdns-col-ttl">{{record.ttl}}s</td>
<td>
<span class="green" v-if="record.isOn">已启用</span>
<span class="grey" v-else>已停用</span>
</td>
<td class="httpdns-col-actions">
<a href="" @click.prevent="editRecord(record.id)">编辑</a> &nbsp;|&nbsp;
<a href="" @click.prevent="toggleRecord(record)">{{record.isOn ? "停用" : "启用"}}</a> &nbsp;|&nbsp;
<a href="" @click.prevent="deleteRecord(record.id)">删除</a>
</td>
</tr>
</tbody>
</table>
<not-found-box v-if="!domain || !domain.id">当前应用暂无可用域名,请先到域名管理中添加域名。</not-found-box>
<not-found-box v-else-if="records.length == 0">当前域名还没有自定义解析规则。</not-found-box>

View File

@@ -0,0 +1,60 @@
Tea.context(function () {
if (typeof this.records == "undefined") {
this.records = [];
}
this.createRecord = function () {
if (!this.domain || !this.domain.id) {
return;
}
teaweb.popup("/httpdns/apps/customRecords/createPopup?appId=" + this.app.id + "&domainId=" + this.domain.id, {
width: "42em",
height: "33em",
title: "新增自定义解析规则",
callback: function () {
teaweb.success("保存成功", function () {
teaweb.reload();
});
}
});
};
this.editRecord = function (recordId) {
if (!this.domain || !this.domain.id) {
return;
}
teaweb.popup("/httpdns/apps/customRecords/createPopup?appId=" + this.app.id + "&domainId=" + this.domain.id + "&recordId=" + recordId, {
width: "42em",
height: "33em",
title: "编辑自定义解析规则",
callback: function () {
teaweb.success("保存成功", function () {
teaweb.reload();
});
}
});
};
this.deleteRecord = function (recordId) {
let that = this;
teaweb.confirm("确定要删除这条自定义解析规则吗?", function () {
that.$post("/httpdns/apps/customRecords/delete")
.params({
appId: that.app.id,
recordId: recordId
})
.refresh();
});
};
this.toggleRecord = function (record) {
let that = this;
that.$post("/httpdns/apps/customRecords/toggle")
.params({
appId: that.app.id,
recordId: record.id,
isOn: record.isOn ? 0 : 1
})
.refresh();
};
});

View File

@@ -0,0 +1,160 @@
{$layout "layout_popup"}
<style>
.httpdns-inline-actions {
margin-top: .6em;
}
.httpdns-inline-actions .count {
color: #8f9aa6;
margin-left: .4em;
}
.httpdns-row {
display: flex;
align-items: center;
gap: .5em;
margin-bottom: .45em;
}
.httpdns-row .field {
margin: 0 !important;
}
.httpdns-row .field.flex {
flex: 1;
}
.httpdns-line-row {
display: flex !important;
align-items: center;
flex-wrap: nowrap;
gap: .5em;
white-space: nowrap;
}
</style>
<h3 v-if="isEditing">编辑自定义解析规则</h3>
<h3 v-else>新增自定义解析规则</h3>
<form method="post" class="ui form" data-tea-action="$" data-tea-success="success">
<csrf-token></csrf-token>
<input type="hidden" name="appId" :value="app.id" />
<input type="hidden" name="domainId" :value="domain.id" />
<input type="hidden" name="domain" :value="record.domain" />
<input type="hidden" name="recordId" :value="record.id" />
<input type="hidden" name="recordItemsJSON" :value="JSON.stringify(recordItems)" />
<table class="ui table definition selectable small">
<tbody>
<tr>
<td class="title">域名 *</td>
<td><strong>{{record.domain}}</strong></td>
</tr>
<tr>
<td class="title">规则名称 *</td>
<td>
<input type="text" name="ruleName" maxlength="50" v-model="record.ruleName" ref="focus" />
<p class="comment">例如:上海电信灰度-v2</p>
</td>
</tr>
<tr>
<td class="title">线路</td>
<td>
<div class="httpdns-line-row">
<select class="ui dropdown auto-width"
style="display:inline-block !important;width:auto !important;margin:0 !important;"
name="lineScope" v-model="record.lineScope" @change="onLineScopeChange">
<option value="china">中国地区</option>
<option value="overseas">境外</option>
</select>
<select class="ui dropdown auto-width"
style="display:inline-block !important;width:auto !important;margin:0 !important;"
name="lineCarrier" v-if="record.lineScope == 'china'" v-model="record.lineCarrier">
<option v-for="carrier in chinaCarriers" :value="carrier">{{carrier}}</option>
</select>
<select class="ui dropdown auto-width"
style="display:inline-block !important;width:auto !important;margin:0 !important;"
name="lineRegion" v-if="record.lineScope == 'china'" v-model="record.lineRegion"
@change="onChinaRegionChange">
<option v-for="region in chinaRegions" :value="region">{{region}}</option>
</select>
<select class="ui dropdown auto-width"
style="display:inline-block !important;width:auto !important;margin:0 !important;"
name="lineProvince" v-if="record.lineScope == 'china'" v-model="record.lineProvince">
<option v-for="province in provinceOptions" :value="province">{{province}}</option>
</select>
<select class="ui dropdown auto-width"
style="display:inline-block !important;width:auto !important;margin:0 !important;"
name="lineContinent" v-if="record.lineScope == 'overseas'" v-model="record.lineContinent"
@change="onContinentChange">
<option v-for="continent in continents" :value="continent">{{continent}}</option>
</select>
<select class="ui dropdown auto-width"
style="display:inline-block !important;width:auto !important;margin:0 !important;"
name="lineCountry" v-if="record.lineScope == 'overseas'" v-model="record.lineCountry">
<option v-for="country in countryOptions" :value="country">{{country}}</option>
</select>
</div>
</td>
</tr>
<tr>
<td class="title">解析记录值 *</td>
<td>
<div style="margin-bottom: 0.5em;">
<div class="ui checkbox" style="margin-bottom: 0.5em;">
<input type="checkbox" name="weightEnabled" value="1" v-model="record.weightEnabled" />
<label>启用权重调度设置</label>
</div>
</div>
<div class="httpdns-row" v-for="(item, index) in recordItems">
<div class="field">
<select class="ui dropdown auto-width" v-model="item.type">
<option value="A">A</option>
<option value="AAAA">AAAA</option>
</select>
</div>
<div class="field flex">
<input type="text" placeholder="记录值" v-model="item.value" />
</div>
<div class="field" v-if="record.weightEnabled">
<input type="text" style="width:7em;" placeholder="权重1-100" v-model="item.weight" />
</div>
<div class="field">
<a href="" @click.prevent="removeRecordItem(index)" title="删除"><i
class="icon trash alternate outline"></i></a>
</div>
</div>
<div class="httpdns-inline-actions">
<a href="" @click.prevent="addRecordItem" :class="{disabled: recordItems.length >= 10}">
<i class="icon plus circle"></i>添加记录值
</a>
<span class="count">{{recordItems.length}}/10</span>
</div>
</td>
</tr>
<tr>
<td class="title">TTL *</td>
<td>
<div class="ui input right labeled">
<input type="text" name="ttl" maxlength="5" style="width:8em;" v-model="record.ttl" />
<span class="ui basic label"></span>
</div>
</td>
</tr>
<tr>
<td class="title">状态</td>
<td>
<div class="ui checkbox">
<input type="checkbox" name="isOn" value="1" v-model="record.isOn" />
<label>启用当前规则</label>
</div>
</td>
</tr>
</tbody>
</table>
<submit-btn></submit-btn>
</form>

View File

@@ -0,0 +1,155 @@
Tea.context(function () {
var vm = this;
if (typeof vm.record == "undefined" || vm.record == null) {
vm.record = {};
}
vm.chinaCarriers = ["默认", "电信", "联通", "移动", "教育网", "鹏博士", "广电"];
vm.chinaRegions = ["默认", "东北", "华北", "华东", "华南", "华中", "西北", "西南"];
vm.chinaRegionProvinces = {
"默认": ["默认"],
"东北": ["默认", "辽宁", "吉林", "黑龙江"],
"华北": ["默认", "北京", "天津", "河北", "山西", "内蒙古"],
"华东": ["默认", "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东"],
"华南": ["默认", "广东", "广西", "海南"],
"华中": ["默认", "河南", "湖北", "湖南"],
"西北": ["默认", "陕西", "甘肃", "青海", "宁夏", "新疆"],
"西南": ["默认", "重庆", "四川", "贵州", "云南", "西藏"]
};
vm.continents = ["默认", "非洲", "南极洲", "亚洲", "欧洲", "北美洲", "南美洲", "大洋洲"];
vm.continentCountries = {
"默认": ["默认"],
"非洲": ["默认", "南非", "埃及", "尼日利亚", "肯尼亚", "摩洛哥"],
"南极洲": ["默认"],
"亚洲": ["默认", "中国香港", "中国澳门", "中国台湾", "日本", "韩国", "新加坡", "印度", "泰国", "越南"],
"欧洲": ["默认", "德国", "英国", "法国", "荷兰", "西班牙", "意大利", "俄罗斯"],
"北美洲": ["默认", "美国", "加拿大", "墨西哥"],
"南美洲": ["默认", "巴西", "阿根廷", "智利", "哥伦比亚"],
"大洋洲": ["默认", "澳大利亚", "新西兰"]
};
vm.parseJSONList = function (raw) {
if (typeof raw == "undefined" || raw == null || raw == "") {
return [];
}
if (Array.isArray(raw)) {
return raw;
}
try {
var list = JSON.parse(raw);
if (Array.isArray(list)) {
return list;
}
} catch (e) {
}
return [];
};
vm.normalizeBoolean = function (value, defaultValue) {
if (typeof value == "boolean") {
return value;
}
if (typeof value == "number") {
return value > 0;
}
if (typeof value == "string") {
value = value.toLowerCase();
return value == "1" || value == "true" || value == "yes" || value == "on";
}
return defaultValue;
};
vm.record.lineScope = vm.record.lineScope == "overseas" ? "overseas" : "china";
vm.record.lineCarrier = vm.record.lineCarrier || "默认";
vm.record.lineRegion = vm.record.lineRegion || "默认";
vm.record.lineProvince = vm.record.lineProvince || "默认";
vm.record.lineContinent = vm.record.lineContinent || "默认";
vm.record.lineCountry = vm.record.lineCountry || "默认";
vm.record.ruleName = vm.record.ruleName || "";
vm.record.ttl = vm.record.ttl || 30;
vm.record.weightEnabled = vm.normalizeBoolean(vm.record.weightEnabled, false);
vm.record.isOn = vm.normalizeBoolean(vm.record.isOn, true);
vm.recordItems = vm.parseJSONList(vm.record.recordItemsJson);
if (vm.recordItems.length == 0) {
vm.recordItems.push({type: "A", value: "", weight: 100});
} else {
for (var i = 0; i < vm.recordItems.length; i++) {
var item = vm.recordItems[i];
if (item.type != "A" && item.type != "AAAA") {
item.type = "A";
}
if (typeof item.weight == "undefined" || item.weight == null || item.weight === "") {
item.weight = 100;
}
}
}
vm.provinceOptions = ["默认"];
vm.countryOptions = ["默认"];
vm.refreshProvinceOptions = function () {
var provinces = vm.chinaRegionProvinces[vm.record.lineRegion];
if (!Array.isArray(provinces) || provinces.length == 0) {
provinces = ["默认"];
}
vm.provinceOptions = provinces;
if (vm.provinceOptions.indexOf(vm.record.lineProvince) < 0) {
vm.record.lineProvince = vm.provinceOptions[0];
}
};
vm.refreshCountryOptions = function () {
var countries = vm.continentCountries[vm.record.lineContinent];
if (!Array.isArray(countries) || countries.length == 0) {
countries = ["默认"];
}
vm.countryOptions = countries;
if (vm.countryOptions.indexOf(vm.record.lineCountry) < 0) {
vm.record.lineCountry = vm.countryOptions[0];
}
};
vm.onChinaRegionChange = function () {
vm.refreshProvinceOptions();
};
vm.onContinentChange = function () {
vm.refreshCountryOptions();
};
vm.onLineScopeChange = function () {
if (vm.record.lineScope == "overseas") {
vm.record.lineContinent = vm.record.lineContinent || "默认";
vm.refreshCountryOptions();
} else {
vm.record.lineCarrier = vm.record.lineCarrier || "默认";
vm.record.lineRegion = vm.record.lineRegion || "默认";
vm.refreshProvinceOptions();
}
};
vm.addRecordItem = function () {
if (vm.recordItems.length >= 10) {
return;
}
vm.recordItems.push({type: "A", value: "", weight: 100});
};
vm.removeRecordItem = function (index) {
if (index < 0 || index >= vm.recordItems.length) {
return;
}
vm.recordItems.splice(index, 1);
if (vm.recordItems.length == 0) {
vm.recordItems.push({type: "A", value: "", weight: 100});
}
};
vm.onLineScopeChange();
});

View File

@@ -0,0 +1,12 @@
{$layout}
<second-menu>
<a class="item" :href="'/httpdns/apps/domains?appId=' + app.id">{{app.name}}</a>
<span class="item disabled" style="padding-left: 0; padding-right: 0">&raquo;</span>
<div class="item"><strong>删除应用</strong></div>
</second-menu>
<div class="buttons-box">
<button class="ui button red" type="button" @click.prevent="deleteApp(app.id)">删除当前应用</button>
<p class="comment" style="margin-top: .8em;">包含{{domainCount}}域名</p>
</div>

View File

@@ -0,0 +1,16 @@
Tea.context(function () {
this.deleteApp = function (appId) {
let that = this;
teaweb.confirm("确定要删除此应用吗?", function () {
that.$post("/httpdns/apps/delete")
.params({
appId: appId
})
.success(function () {
teaweb.success("删除成功", function () {
window.location = "/httpdns/apps";
});
});
});
};
});

View File

@@ -0,0 +1,36 @@
# Android SDK
## 初始化
```java
new InitConfig.Builder()
.setContext(context)
.setPrimaryServiceHost("httpdns-a.example.com")
.setBackupServiceHost("httpdns-b.example.com")
.setServicePort(443)
.setSecretKey("your-sign-secret")
.setEnableHttps(true)
.buildFor("app1f1ndpo9");
```
## 解析
```java
HTTPDNSResult result = httpDnsService.getHttpDnsResultForHostSyncNonBlocking(
"api.business.com",
RequestIpType.auto,
null,
null
);
```
## 官方业务适配器
```java
HttpDnsHttpAdapter adapter = HttpDns.buildHttpClientAdapter(httpDnsService);
HttpDnsAdapterResponse resp = adapter.execute(
new HttpDnsAdapterRequest("GET", "https://api.business.com/v1/ping")
);
```
固定策略IP 直连 + 空 SNI + Host=真实域名,不回退到带 SNI。

View File

@@ -0,0 +1,35 @@
# Flutter SDK
## 初始化
```dart
await AliyunHttpdns.init(
appId: 'app1f1ndpo9',
primaryServiceHost: 'httpdns-a.example.com',
backupServiceHost: 'httpdns-b.example.com',
servicePort: 443,
secretKey: 'your-sign-secret',
);
await AliyunHttpdns.build();
```
## 解析
```dart
final result = await AliyunHttpdns.resolveHostSyncNonBlocking(
'api.business.com',
ipType: 'both',
);
```
## 官方业务适配器
```dart
final adapter = AliyunHttpdns.createHttpAdapter();
final resp = await adapter.request(
Uri.parse('https://api.business.com/v1/ping'),
method: 'GET',
);
```
固定策略IP 直连 + 空 SNI + Host=真实域名,不回退到带 SNI。

View File

@@ -0,0 +1,31 @@
# iOS SDK
## 初始化
```objc
HttpdnsEdgeService *service = [[HttpdnsEdgeService alloc]
initWithAppId:@"app1f1ndpo9"
primaryServiceHost:@"httpdns-a.example.com"
backupServiceHost:@"httpdns-b.example.com"
servicePort:443
signSecret:@"your-sign-secret"];
```
## 解析
```objc
[service resolveHost:@"api.business.com" queryType:@"A" completion:^(HttpdnsEdgeResolveResult * _Nullable result, NSError * _Nullable error) {
// result.ipv4s / result.ipv6s
}];
```
## 官方业务适配器
```objc
NSURL *url = [NSURL URLWithString:@"https://api.business.com/v1/ping"];
[service requestURL:url method:@"GET" headers:nil body:nil completion:^(NSData * _Nullable data, NSHTTPURLResponse * _Nullable response, NSError * _Nullable error) {
// handle
}];
```
固定策略IP 直连 + 空 SNI + Host=真实域名,不回退到带 SNI。

View File

@@ -0,0 +1,62 @@
{$layout}
<div class="margin"></div>
<style>
.httpdns-domains-table .httpdns-domains-op {
white-space: nowrap;
}
</style>
<second-menu>
<a class="item" :href="'/httpdns/apps/domains?appId=' + app.id">{{app.name}}</a>
<span class="item disabled" style="padding-left: 0; padding-right: 0">&raquo;</span>
<div class="item"><strong>域名列表</strong></div>
<a href="" class="item" @click.prevent="bindDomain">创建域名</a>
</second-menu>
<form class="ui form small" @submit.prevent="doSearch">
<div class="ui fields inline">
<div class="ui field">
<input type="text" v-model.trim="keywordInput" placeholder="按域名筛选..." />
</div>
<div class="ui field">
<button type="submit" class="ui button small">搜索</button>
&nbsp;
<a href="" v-if="keyword && keyword.length > 0" @click.prevent="clearSearch">[清除条件]</a>
</div>
</div>
</form>
<table class="ui table selectable celled httpdns-domains-table" v-if="filteredDomains().length > 0">
<colgroup>
<col style="width:60%;" />
<col style="width:20%;" />
<col style="width:20%;" />
</colgroup>
<thead>
<tr>
<th>服务域名</th>
<th class="width10">规则策略</th>
<th class="httpdns-domains-op">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="domain in filteredDomains()">
<td>
<div><strong>{{domain.name}}</strong></div>
</td>
<td>
<a
:href="'/httpdns/apps/customRecords?appId=' + app.id + '&domainId=' + domain.id">{{domain.customRecordCount}}</a>
</td>
<td class="httpdns-domains-op">
<a :href="'/httpdns/apps/customRecords?appId=' + app.id + '&domainId=' + domain.id">自定义解析</a>
&nbsp;|&nbsp;
<a href="" @click.prevent="deleteDomain(domain.id)">解绑</a>
</td>
</tr>
</tbody>
</table>
<not-found-box v-if="!domains || domains.length == 0">当前应用尚未绑定域名。</not-found-box>
<not-found-box v-if="domains && domains.length > 0 && filteredDomains().length == 0">没有匹配的域名。</not-found-box>

View File

@@ -0,0 +1,72 @@
Tea.context(function () {
if (typeof this.keywordInput == "undefined" || this.keywordInput == null) {
this.keywordInput = "";
}
if (typeof this.keyword == "undefined" || this.keyword == null) {
this.keyword = "";
}
if (typeof this.domains == "undefined" || this.domains == null) {
this.domains = [];
}
this.keywordInput = String(this.keyword);
this.doSearch = function () {
this.keyword = String(this.keywordInput || "").trim();
};
this.clearSearch = function () {
this.keywordInput = "";
this.keyword = "";
};
this.filteredDomains = function () {
let domains = Array.isArray(this.domains) ? this.domains : [];
let keyword = String(this.keyword || "").trim().toLowerCase();
if (keyword.length == 0) {
return domains;
}
return domains.filter(function (domain) {
let name = (domain.name || "").toLowerCase();
return name.indexOf(keyword) >= 0;
});
};
this.bindDomain = function () {
teaweb.popup("/httpdns/apps/domains/createPopup?appId=" + this.app.id, {
height: "24em",
width: "46em",
title: "添加域名",
callback: function () {
teaweb.success("保存成功", function () {
teaweb.reload();
});
}
});
};
this.deleteApp = function () {
let that = this;
teaweb.confirm("确定要删除当前应用吗?", function () {
that.$post("/httpdns/apps/delete")
.params({
appId: that.app.id
})
.success(function () {
window.location = "/httpdns/apps";
});
});
};
this.deleteDomain = function (domainId) {
let that = this;
teaweb.confirm("确定要解绑这个域名吗?", function () {
that.$post("/httpdns/apps/domains/delete")
.params({
domainId: domainId
})
.refresh();
});
};
});

View File

@@ -0,0 +1,18 @@
{$layout "layout_popup"}
<h3>添加域名</h3>
<form method="post" class="ui form" data-tea-action="$" data-tea-success="success">
<csrf-token></csrf-token>
<input type="hidden" name="appId" :value="app.id" />
<table class="ui table definition selectable">
<tr>
<td class="title">域名 *</td>
<td>
<input type="text" name="domain" maxlength="255" placeholder="api.example.com" ref="focus" />
<p class="comment">请输入完整 FQDN例如 <code>api.example.com</code></p>
</td>
</tr>
</table>
<submit-btn></submit-btn>
</form>

View File

@@ -0,0 +1,71 @@
{$layout}
{$template "menu"}
<div class="ui margin"></div>
<style>
.httpdns-apps-table .httpdns-apps-op {
white-space: nowrap;
}
</style>
<div>
<form method="get" class="ui form" action="/httpdns/apps">
<div class="ui fields inline">
<div class="ui field">
<input type="text" name="keyword" style="width:16em" placeholder="AppID 或应用名称..." :value="keyword" />
</div>
<div class="ui field">
<button type="submit" class="ui button">查询</button>
<a href="/httpdns/apps" v-if="keyword.length > 0">[清除条件]</a>
</div>
</div>
</form>
<p class="ui message" v-if="apps.length == 0">暂时没有符合条件的 HTTPDNS 应用。</p>
<table class="ui table selectable celled httpdns-apps-table" v-if="apps.length > 0">
<colgroup>
<col style="width:32%;" />
<col style="width:26%;" />
<col style="width:12%;" />
<col style="width:10%;" />
<col style="width:20%;" />
</colgroup>
<thead>
<tr>
<th>应用名称</th>
<th>AppID</th>
<th class="center">绑定域名数</th>
<th class="center">状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="app in apps">
<td>
<a :href="'/httpdns/apps/app?appId=' + app.id">
<strong>
<keyword :v-word="keyword">{{app.name}}</keyword>
</strong>
</a>
</td>
<td>
<code>{{app.appId}}</code>
<copy-icon :text="app.appId"></copy-icon>
</td>
<td class="center"><a :href="'/httpdns/apps/domains?appId=' + app.id">{{app.domainCount}}</a></td>
<td class="center">
<label-on :v-is-on="app.isOn"></label-on>
</td>
<td class="httpdns-apps-op">
<a :href="'/httpdns/apps/app?appId=' + app.id">域名管理</a> &nbsp;|&nbsp;
<a :href="'/httpdns/apps/app/settings?appId=' + app.id">应用设置</a> &nbsp;|&nbsp;
<a :href="'/httpdns/apps/sdk?appId=' + app.id">SDK集成</a>
</td>
</tr>
</tbody>
</table>
<div class="page" v-html="page"></div>
</div>

View File

@@ -0,0 +1,9 @@
Tea.context(function () {
if (typeof this.apps == "undefined") {
this.apps = [];
}
this.createApp = function () {
window.location = "/httpdns/apps/create";
};
});

View File

@@ -0,0 +1,78 @@
{$layout}
{$template "menu"}
<div class="ui margin"></div>
<h3 class="ui header">HTTPDNS 鍏ㄥ眬绛栫暐</h3>
<form method="post" class="ui form" data-tea-action="$" data-tea-success="success">
<csrf-token></csrf-token>
<table class="ui table definition selectable">
<tr>
<td class="title">鍏ㄥ眬榛樿瑙f瀽 TTL</td>
<td><input type="text" name="defaultTTL" maxlength="8" v-model="policies.defaultTTL" /> 绉?/td>
</tr>
<tr>
<td class="title">鍏ㄥ眬榛樿 SNI 绛夌骇</td>
<td>
<select name="defaultSniPolicy" class="ui dropdown auto-width" v-model="policies.defaultSniPolicy">
<option value="level1">level1锛堝浐瀹?SNI锛?/option>
<option value="level2">level2锛堥殣鍖?SNI锛?/option>
<option value="level3">level3锛圗CH锛?/option>
</select>
<div class="grey small" v-if="policies.defaultSniPolicy == 'level1'" style="margin-top:.5em;">
level1 浠呬娇鐢ㄥ浐瀹?SNI锛屼笉鍚敤 ECS 鍜岃瘉涔︽牎楠岀瓥鐣ャ€? </div>
<div class="grey small" v-else-if="policies.defaultSniPolicy == 'level2'" style="margin-top:.5em;">
level2 浠呭惎鐢ㄩ殣鍖?SNI锛屼笉瑕佹眰閰嶇疆 ECS 涓庤瘉涔︾瓥鐣ャ€? </div>
<div class="grey small" v-else style="margin-top:.5em;">
level3 鍚敤 ECH锛屽缓璁悓鏃堕厤缃?ECS 涓庤瘉涔︽牎楠岀瓥鐣ャ€? </div>
</td>
</tr>
<tr>
<td class="title">鍏ㄥ眬闄嶇骇瓒呮椂</td>
<td><input type="text" name="defaultFallbackMs" maxlength="8" v-model="policies.defaultFallbackMs" /> 姣</td>
</tr>
<tr v-show="policies.defaultSniPolicy == 'level3'">
<td class="title">全局 ECS 掩码策略</td>
<td>
<select name="ecsMode" class="ui dropdown auto-width" v-model="policies.ecsMode">
<option value="off">鍏抽棴</option>
<option value="auto">鑷姩</option>
<option value="custom">鑷畾涔?/option>
</select>
<span v-if="policies.ecsMode == 'custom'">
<span class="grey" style="margin-left:.8em;">IPv4 /</span>
<input type="text" name="ecsIPv4Prefix" maxlength="3" v-model="policies.ecsIPv4Prefix" style="width:4.5em;" />
<span class="grey" style="margin-left:.8em;">IPv6 /</span>
<input type="text" name="ecsIPv6Prefix" maxlength="3" v-model="policies.ecsIPv6Prefix" style="width:4.5em;" />
</span>
<span v-else class="grey" style="margin-left:.8em;">仅在“自定义”模式下配置掩码。</span>
<input type="hidden" name="ecsIPv4Prefix" v-model="policies.ecsIPv4Prefix" v-if="policies.ecsMode != 'custom'" />
<input type="hidden" name="ecsIPv6Prefix" v-model="policies.ecsIPv6Prefix" v-if="policies.ecsMode != 'custom'" />
</td>
</tr>
<tr v-show="policies.defaultSniPolicy == 'level3'">
<td class="title">鍏ㄥ眬璇佷功鏍¢獙绛栫暐</td>
<td>
<span class="grey">证书指纹校验Pinning</span>
<select name="pinningMode" class="ui dropdown auto-width" v-model="policies.pinningMode" style="margin-left:.4em;">
<option value="off">鍏抽棴</option>
<option value="report">瑙傚療妯″紡</option>
<option value="enforce">寮哄埗鏍¢獙</option>
</select>
<span class="grey" style="margin-left:1.2em;">证书 SAN 域名校验</span>
<select name="sanMode" class="ui dropdown auto-width" v-model="policies.sanMode" style="margin-left:.4em;">
<option value="off">鍏抽棴</option>
<option value="report">瑙傚療妯″紡</option>
<option value="strict">涓ユ牸鏍¢獙</option>
</select>
</td>
</tr>
</table>
<submit-btn></submit-btn>
</form>

View File

@@ -0,0 +1,40 @@
Tea.context(function () {
this.success = NotifyReloadSuccess("保存成功");
this.$delay(function () {
this.$watch("policies.defaultSniPolicy", function (level) {
if (level == "level1" || level == "level2") {
this.policies.ecsMode = "off";
this.policies.pinningMode = "off";
this.policies.sanMode = "off";
return;
}
if (level == "level3") {
if (this.policies.ecsMode == "off") {
this.policies.ecsMode = "auto";
}
if (this.policies.pinningMode == "off") {
this.policies.pinningMode = "report";
}
if (this.policies.sanMode == "off") {
this.policies.sanMode = "strict";
}
}
});
this.$watch("policies.ecsMode", function (mode) {
if (this.policies.defaultSniPolicy != "level3") {
return;
}
if (mode == "custom") {
if (!this.policies.ecsIPv4Prefix || this.policies.ecsIPv4Prefix <= 0) {
this.policies.ecsIPv4Prefix = 24;
}
if (!this.policies.ecsIPv6Prefix || this.policies.ecsIPv6Prefix <= 0) {
this.policies.ecsIPv6Prefix = 56;
}
}
});
});
});

View File

@@ -0,0 +1,79 @@
{$layout}
<style>
.httpdns-sdk-cards {
margin-top: 1em;
}
.httpdns-sdk-cards > .card {
min-height: 168px;
}
.httpdns-sdk-desc {
margin-top: .6em;
color: #5e6c7b;
}
.httpdns-sdk-meta {
margin-top: .35em;
color: #9aa6b2;
font-size: 12px;
}
.httpdns-sdk-actions {
display: inline-flex;
align-items: center;
gap: .6em;
flex-wrap: wrap;
}
.httpdns-sdk-actions .button {
padding-left: .8em !important;
padding-right: .8em !important;
}
</style>
<second-menu>
<a class="item" :href="'/httpdns/apps/domains?appId=' + app.id">{{app.name}}</a>
<span class="item disabled" style="padding-left: 0; padding-right: 0">&raquo;</span>
<div class="item"><strong>SDK 集成</strong></div>
</second-menu>
<div class="ui three stackable cards httpdns-sdk-cards">
<div class="card">
<div class="content">
<div class="header"><i class="icon android green"></i> Android SDK</div>
<div class="httpdns-sdk-meta">Java / Kotlin</div>
<div class="description httpdns-sdk-desc">适用于 Android 客户端接入。</div>
</div>
<div class="extra content">
<div class="httpdns-sdk-actions">
<a class="ui button compact mini basic" href="/httpdns/apps/sdk/download?platform=android" @click="downloadSDK('android', $event)"><i class="icon download"></i> 下载 SDK</a>
<a class="ui button compact mini basic" href="/httpdns/apps/sdk/doc?platform=android" @click="downloadDoc('android', $event)"><i class="icon book"></i> 下载文档</a>
</div>
</div>
</div>
<div class="card">
<div class="content">
<div class="header"><i class="icon apple grey"></i> iOS SDK</div>
<div class="httpdns-sdk-meta">Objective-C / Swift</div>
<div class="description httpdns-sdk-desc">适用于 iOS 客户端接入。</div>
</div>
<div class="extra content">
<div class="httpdns-sdk-actions">
<a class="ui button compact mini basic" href="/httpdns/apps/sdk/download?platform=ios" @click="downloadSDK('ios', $event)"><i class="icon download"></i> 下载 SDK</a>
<a class="ui button compact mini basic" href="/httpdns/apps/sdk/doc?platform=ios" @click="downloadDoc('ios', $event)"><i class="icon book"></i> 下载文档</a>
</div>
</div>
</div>
<div class="card">
<div class="content">
<div class="header"><i class="icon mobile alternate blue"></i> Flutter SDK</div>
<div class="httpdns-sdk-meta">Dart / Plugin</div>
<div class="description httpdns-sdk-desc">适用于 Flutter 跨平台接入。</div>
</div>
<div class="extra content">
<div class="httpdns-sdk-actions">
<a class="ui button compact mini basic" href="/httpdns/apps/sdk/download?platform=flutter" @click="downloadSDK('flutter', $event)"><i class="icon download"></i> 下载 SDK</a>
<a class="ui button compact mini basic" href="/httpdns/apps/sdk/doc?platform=flutter" @click="downloadDoc('flutter', $event)"><i class="icon book"></i> 下载文档</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,123 @@
Tea.context(function () {
this.downloadSDK = function (platform, event) {
this.checkAndDownload(platform, "sdk", event)
}
this.downloadDoc = function (platform, event) {
this.checkAndDownload(platform, "doc", event)
}
this.checkAndDownload = function (platform, type, event) {
if (event != null && typeof event.preventDefault == "function") {
event.preventDefault()
}
this.$get("/httpdns/apps/sdk/check")
.params({
platform: platform,
type: type
})
.success(function (resp) {
let data = (resp != null && resp.data != null) ? resp.data : {}
if (!data.exists) {
teaweb.warn(data.message || "当前暂无可下载文件")
return
}
if (typeof data.url == "string" && data.url.length > 0) {
this.downloadByBlob(data.url, platform, type)
return
}
teaweb.warn("下载地址生成失败,请稍后重试")
}.bind(this))
}
this.downloadByBlob = function (url, platform, type) {
let defaultFileName = "httpdns-sdk-" + platform + (type == "doc" ? ".md" : ".zip")
let xhr = new XMLHttpRequest()
xhr.open("GET", url, true)
xhr.responseType = "blob"
xhr.onload = function () {
if (xhr.status < 200 || xhr.status >= 300) {
teaweb.warn("下载失败HTTP " + xhr.status + "")
return
}
if (xhr.status == 204) {
teaweb.warn("下载被浏览器扩展或下载工具拦截,请暂时关闭相关扩展后重试")
return
}
let contentType = (xhr.getResponseHeader("Content-Type") || "").toLowerCase()
if (contentType.indexOf("application/json") >= 0) {
let reader = new FileReader()
reader.onload = function () {
try {
let json = JSON.parse(reader.result)
teaweb.warn((json && json.message) ? json.message : "下载失败,请稍后重试")
} catch (_e) {
teaweb.warn("下载失败,请稍后重试")
}
}
reader.readAsText(xhr.response)
return
}
let disposition = xhr.getResponseHeader("Content-Disposition") || ""
let fileName = xhr.getResponseHeader("X-SDK-Filename") || this.parseFileName(disposition) || defaultFileName
if (xhr.response == null || xhr.response.size <= 0) {
teaweb.warn("下载文件为空,请检查已上传 SDK 包是否完整")
return
}
this.saveBlob(xhr.response, fileName)
}.bind(this)
xhr.onerror = function () {
teaweb.warn("下载失败,请检查网络后重试")
}
xhr.send()
}
this.parseFileName = function (disposition) {
if (typeof disposition != "string" || disposition.length == 0) {
return ""
}
let utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match != null && utf8Match.length > 1) {
try {
return decodeURIComponent(utf8Match[1])
} catch (_e) {
}
}
let plainMatch = disposition.match(/filename="?([^";]+)"?/i)
if (plainMatch != null && plainMatch.length > 1) {
return plainMatch[1]
}
return ""
}
this.saveBlob = function (blob, fileName) {
if (window.navigator != null && typeof window.navigator.msSaveOrOpenBlob == "function") {
window.navigator.msSaveOrOpenBlob(blob, fileName)
return
}
let objectURL = window.URL.createObjectURL(blob)
let a = document.createElement("a")
a.style.display = "none"
a.href = objectURL
a.download = fileName
document.body.appendChild(a)
a.click()
setTimeout(function () {
window.URL.revokeObjectURL(objectURL)
if (a.parentNode != null) {
a.parentNode.removeChild(a)
}
}, 30000)
}
})

View File

@@ -0,0 +1,3 @@
<first-menu>
<menu-item href="/httpdns/resolveLogs" code="index">访问日志</menu-item>
</first-menu>

View File

@@ -0,0 +1,94 @@
{$layout}
<div class="margin"></div>
<style>
.httpdns-log-summary {
color: #556070;
font-size: 12px;
line-height: 1.6;
white-space: nowrap;
}
.httpdns-log-summary code {
font-size: 12px;
}
</style>
<div>
<form method="get" action="/httpdns/resolveLogs" class="ui form small" autocomplete="off">
<div class="ui fields inline">
<div class="ui field">
<select class="ui dropdown" name="clusterId" v-model="clusterId">
<option value="">[HTTPDNS服务域名]</option>
<option v-for="cluster in clusters" :value="cluster.id">{{cluster.displayName}}</option>
</select>
</div>
<div class="ui field">
<input type="text" name="appId" v-model="appId" placeholder="AppID" />
</div>
<div class="ui field">
<input type="text" name="domain" v-model="domain" placeholder="域名" />
</div>
<div class="ui field">
<select class="ui dropdown" name="status" v-model="status">
<option value="">[状态]</option>
<option value="success">解析成功</option>
<option value="failed">解析失败</option>
</select>
</div>
<div class="ui field">
<input type="text" name="keyword" v-model="keyword" placeholder="应用/域名/IP/结果IP" />
</div>
<div class="ui field">
<button type="submit" class="ui button small">查询</button>
</div>
<div class="ui field"
v-if="clusterId.toString().length > 0 || appId.length > 0 || domain.length > 0 || status.length > 0 || keyword.length > 0">
<a href="/httpdns/resolveLogs">[清除条件]</a>
</div>
</div>
</form>
</div>
<div class="margin"></div>
<not-found-box v-if="resolveLogs.length == 0">暂时还没有访问日志。</not-found-box>
<div v-if="resolveLogs.length > 0">
<div style="overflow-x:auto;">
<table class="ui table selectable celled">
<thead>
<tr>
<th>HTTPDNS服务域名</th>
<th>域名</th>
<th>类型</th>
<th>概要</th>
</tr>
</thead>
<tbody>
<tr v-for="log in resolveLogs">
<td>{{log.serviceDomain}}</td>
<td>{{log.domain}}</td>
<td>{{log.query}}</td>
<td>
<div class="httpdns-log-summary">
{{log.time}}
| {{log.appName}} (<code>{{log.appId}}</code>)
| <code>{{log.clientIp}}</code>
| {{log.os}}/{{log.sdkVersion}}
| {{log.query}} {{log.domain}} ->
<code v-if="log.ips && log.ips.length > 0">{{log.ips}}</code>
<span class="grey" v-else>[无记录]</span>
|
<span class="green" v-if="log.status == 'success'"><strong>成功</strong></span>
<span class="red" v-else><strong>失败</strong></span>
<span class="grey" v-if="log.errorCode != 'none'">({{log.errorCode}})</span>
| {{log.costMs}}ms
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,4 @@
<!-- 左侧菜单由 Go Backend 自动生成注入,此处加首行子菜单使其符合标准平台样式 -->
<first-menu>
<menu-item href="/httpdns/sandbox" code="index">解析测试</menu-item>
</first-menu>

View File

@@ -0,0 +1,147 @@
{$layout}
<div class="ui margin"></div>
<div>
<div class="ui grid stackable">
<!-- Left: 解析测试 -->
<div class="six wide column">
<div class="ui segment">
<h4 class="ui header" style="margin-top:0;">解析测试</h4>
<div class="ui form">
<div class="field">
<label>目标应用 *</label>
<select class="ui dropdown" name="appId" v-model="request.appId" @change="onAppChanged">
<option value="">[请选择应用]</option>
<option v-for="app in apps" :value="app.appId">{{app.name}} ({{app.appId}})</option>
</select>
</div>
<div class="field">
<label>HTTPDNS服务域名 *</label>
<select class="ui dropdown" name="clusterId" v-model="request.clusterId">
<option value="">[请选择HTTPDNS服务域名]</option>
<option v-for="cluster in currentClusters" :value="String(cluster.id)">{{cluster.displayName || cluster.name}}</option>
</select>
</div>
<div class="field">
<label>解析域名 *</label>
<select class="ui dropdown" name="domain" v-model="request.domain">
<option value="">[请选择域名]</option>
<option v-for="domain in currentDomains" :value="domain">{{domain}}</option>
</select>
</div>
<div class="field">
<label>模拟客户端 IP</label>
<input type="text" v-model="request.clientIp" placeholder="留空使用当前 IP" />
<p class="comment">用于测试 ECS 掩码与区域调度效果。</p>
</div>
<div class="field">
<label>解析类型</label>
<div class="ui buttons mini">
<button class="ui button" :class="{primary: request.qtype=='A'}"
@click.prevent="request.qtype='A'" type="button">A</button>
<button class="ui button" :class="{primary: request.qtype=='AAAA'}"
@click.prevent="request.qtype='AAAA'" type="button">AAAA</button>
</div>
</div>
<div class="ui divider"></div>
<button class="ui button primary" type="button" @click.prevent="sendTestRequest"
:class="{loading: isRequesting, disabled: isRequesting}">
<i class="icon search"></i> 在线解析
</button>
<button class="ui button basic" type="button" @click.prevent="resetForm">重置</button>
</div>
</div>
</div>
<!-- Right: 解析结果 -->
<div class="ten wide column">
<!-- Empty state -->
<div v-show="!response.hasResult && !isRequesting" class="ui segment placeholder">
<p class="grey" style="text-align:center;">请在左侧配置参数后点击「在线解析」。</p>
</div>
<!-- Loading -->
<div v-show="isRequesting" class="ui segment" style="min-height:200px;">
<div class="ui active dimmer">
<div class="ui text loader">解析中...</div>
</div>
</div>
<!-- Result -->
<div v-show="response.hasResult && !isRequesting">
<!-- Status banner -->
<div class="ui message small" :class="{positive: response.code === 0, negative: response.code !== 0}">
<div class="header">
<span v-if="response.code === 0"><i class="icon check circle"></i> 解析成功</span>
<span v-else><i class="icon times circle"></i> 解析失败 ({{response.code}})</span>
</div>
<p v-if="response.code !== 0">{{response.message}}</p>
<p class="grey small" style="margin-top:.3em;">Request ID: <code>{{response.requestId}}</code></p>
</div>
<!-- Result details -->
<div v-if="response.code === 0 && response.data" style="margin-top: 1em;">
<h5 class="ui header">解析结果</h5>
<div class="ui segment" style="margin-top:.4em;">
<div style="font-weight: 600; margin-bottom: .5em;">请求URL</div>
<div style="word-break: break-all;">
<code>{{response.data.request_url || '-'}}</code>
</div>
</div>
<h5 class="ui header" style="margin-top:1.2em;">解析结果详情</h5>
<div class="ui three column stackable grid" style="margin-top:0;">
<div class="column">
<div class="ui segment">
<div class="grey">客户端 IP</div>
<div style="margin-top:.4em;">
<code>{{response.data.client_ip || request.clientIp || '-'}}</code></div>
</div>
</div>
<div class="column">
<div class="ui segment">
<div class="grey">地区</div>
<div style="margin-top:.4em;">{{response.data.client_region || '-'}}</div>
</div>
</div>
<div class="column">
<div class="ui segment">
<div class="grey">线路</div>
<div style="margin-top:.4em;">{{response.data.line_name || '-'}}</div>
</div>
</div>
</div>
<!-- IP results table -->
<h5 class="ui header" style="margin-top:1.2em;">解析记录</h5>
<table class="ui table celled compact">
<thead>
<tr>
<th>解析域名</th>
<th>解析类型</th>
<th>IP地址</th>
<th>TTL</th>
<th>地区</th>
<th>线路</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in response.resultRows">
<td v-if="idx === 0" :rowspan="response.resultRows.length">{{row.domain ||
request.domain}}</td>
<td>{{row.type || request.qtype}}</td>
<td><code>{{row.ip}}</code></td>
<td>{{row.ttl}}s</td>
<td>{{row.region || '-'}}</td>
<td>{{row.line || '-'}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,161 @@
Tea.context(function () {
this.newRequest = function () {
return {
appId: "",
clusterId: "",
domain: "",
clientIp: "",
qtype: "A"
}
}
this.request = this.newRequest()
this.response = {
hasResult: false,
code: -1,
message: "",
data: null,
requestId: "",
resultRows: []
}
this.isRequesting = false
this.currentDomains = []
this.currentClusters = []
if (typeof this.apps === "undefined" || this.apps == null) {
this.apps = []
}
if (typeof this.clusters === "undefined" || this.clusters == null) {
this.clusters = []
}
this.onAppChanged = function () {
let selectedApp = null
for (let i = 0; i < this.apps.length; i++) {
if (this.apps[i].appId === this.request.appId) {
selectedApp = this.apps[i]
break
}
}
if (selectedApp == null) {
this.currentDomains = []
this.currentClusters = []
this.request.domain = ""
this.request.clusterId = ""
return
}
this.currentDomains = Array.isArray(selectedApp.domains) ? selectedApp.domains : []
if (this.currentDomains.length > 0) {
if (this.currentDomains.indexOf(this.request.domain) < 0) {
this.request.domain = this.currentDomains[0]
}
} else {
this.request.domain = ""
}
let primaryClusterId = (typeof selectedApp.primaryClusterId !== "undefined" && selectedApp.primaryClusterId !== null) ? Number(selectedApp.primaryClusterId) : 0
let backupClusterId = (typeof selectedApp.backupClusterId !== "undefined" && selectedApp.backupClusterId !== null) ? Number(selectedApp.backupClusterId) : 0
let allowed = []
for (let i = 0; i < this.clusters.length; i++) {
let cluster = this.clusters[i]
let clusterId = Number(cluster.id)
if (clusterId <= 0) {
continue
}
if (clusterId === primaryClusterId || clusterId === backupClusterId) {
allowed.push(cluster)
}
}
this.currentClusters = allowed
if (allowed.length > 0) {
if (!allowed.some((c) => String(c.id) === String(this.request.clusterId))) {
this.request.clusterId = String(allowed[0].id)
}
} else {
this.request.clusterId = ""
}
}
this.normalizeResultRows = function (data) {
if (typeof data === "undefined" || data == null) {
return []
}
if (Array.isArray(data.records) && data.records.length > 0) {
return data.records
}
let rows = []
let ips = Array.isArray(data.ips) ? data.ips : []
let domain = this.request.domain
let qtype = this.request.qtype
let ttl = data.ttl || 0
let region = data.client_region || "-"
let line = data.line_name || "-"
ips.forEach(function (ip) {
rows.push({
domain: domain,
type: qtype,
ip: ip,
ttl: ttl,
region: region,
line: line
})
})
return rows
}
this.sendTestRequest = function () {
if (this.request.appId.length === 0) {
teaweb.warn("请选择目标应用")
return
}
if (this.request.clusterId.length === 0) {
teaweb.warn("当前应用未绑定可用的 HTTPDNS 服务域名,请先在应用设置中配置主/备集群")
return
}
if (this.request.domain.length === 0) {
teaweb.warn("请选择要解析的域名")
return
}
this.isRequesting = true
this.response.hasResult = false
let payload = Object.assign({}, this.request)
this.$post("/httpdns/sandbox/test")
.params(payload)
.success(function (resp) {
this.response = resp.data.result
this.response.hasResult = true
this.response.resultRows = this.normalizeResultRows(this.response.data)
})
.done(function () {
this.isRequesting = false
})
}
this.resetForm = function () {
this.request = this.newRequest()
this.currentDomains = []
this.currentClusters = []
this.response = {
hasResult: false,
code: -1,
message: "",
data: null,
requestId: "",
resultRows: []
}
}
})