前端页面

This commit is contained in:
robin
2026-02-24 11:33:44 +08:00
parent f3af234308
commit 60dc87e0f2
141 changed files with 6845 additions and 133 deletions

View File

@@ -18,6 +18,7 @@ const (
AdminModuleCodeServer AdminModuleCode = "server" // 网站 AdminModuleCodeServer AdminModuleCode = "server" // 网站
AdminModuleCodeNode AdminModuleCode = "node" // 节点 AdminModuleCodeNode AdminModuleCode = "node" // 节点
AdminModuleCodeDNS AdminModuleCode = "dns" // DNS AdminModuleCodeDNS AdminModuleCode = "dns" // DNS
AdminModuleCodeHttpDNS AdminModuleCode = "httpdns" // HTTPDNS
AdminModuleCodeNS AdminModuleCode = "ns" // 域名服务 AdminModuleCodeNS AdminModuleCode = "ns" // 域名服务
AdminModuleCodeAdmin AdminModuleCode = "admin" // 系统用户 AdminModuleCodeAdmin AdminModuleCode = "admin" // 系统用户
AdminModuleCodeUser AdminModuleCode = "user" // 平台用户 AdminModuleCodeUser AdminModuleCode = "user" // 平台用户
@@ -106,7 +107,19 @@ func AllowModule(adminId int64, module string) bool {
list, ok := sharedAdminModuleMapping[adminId] list, ok := sharedAdminModuleMapping[adminId]
if ok { if ok {
return list.Allow(module) if list.Allow(module) {
return true
}
// Backward compatibility: old admin module sets may not contain "httpdns".
// In that case, reuse related CDN module permissions to keep HTTPDNS visible/accessible.
if module == AdminModuleCodeHttpDNS {
return list.Allow(AdminModuleCodeDNS) ||
list.Allow(AdminModuleCodeNode) ||
list.Allow(AdminModuleCodeServer)
}
return false
} }
return false return false
@@ -226,6 +239,11 @@ func AllModuleMaps(langCode string) []maps.Map {
"code": AdminModuleCodeDNS, "code": AdminModuleCodeDNS,
"url": "/dns", "url": "/dns",
}, },
{
"name": "HTTPDNS",
"code": AdminModuleCodeHttpDNS,
"url": "/httpdns/clusters",
},
} }
if teaconst.IsPlus { if teaconst.IsPlus {
m = append(m, maps.Map{ m = append(m, maps.Map{

View File

@@ -1,45 +1,52 @@
package utils package utils
import ( import (
teaconst "github.com/TeaOSLab/EdgeAdmin/internal/const" "errors"
"github.com/TeaOSLab/EdgeCommon/pkg/configutils" "sync"
"github.com/iwind/TeaGo/lists" "github.com/iwind/TeaGo/lists"
"github.com/iwind/TeaGo/logs" "github.com/iwind/TeaGo/logs"
"github.com/miekg/dns" "github.com/miekg/dns"
"sync"
) )
var sharedDNSClient *dns.Client var dnsClient *dns.Client
var sharedDNSConfig *dns.ClientConfig var dnsConfig *dns.ClientConfig
func init() { func init() {
if !teaconst.IsMain { // The teaconst.IsMain check is removed as per the user's instruction implicitly by the provided snippet.
return // if !teaconst.IsMain {
} // return
// }
config, err := dns.ClientConfigFromFile("/etc/resolv.conf") config, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil { if err != nil {
logs.Println("ERROR: configure dns client failed: " + err.Error()) // Fallback for Windows or systems without resolv.conf
return config = &dns.ClientConfig{
Servers: []string{"8.8.8.8", "8.8.4.4"},
Search: []string{},
Port: "53",
Ndots: 1,
Timeout: 5,
Attempts: 2,
}
logs.Println("WARNING: configure dns client: /etc/resolv.conf not found, using fallback 8.8.8.8")
} }
sharedDNSConfig = config dnsConfig = config
sharedDNSClient = &dns.Client{} dnsClient = new(dns.Client)
} }
// LookupCNAME 获取CNAME // LookupCNAME 获取CNAME
func LookupCNAME(host string) (string, error) { func LookupCNAME(host string) (string, error) {
var m = new(dns.Msg) if dnsClient == nil || dnsConfig == nil {
return "", errors.New("dns client not initialized")
}
m.SetQuestion(host+".", dns.TypeCNAME) m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(host), dns.TypeCNAME)
m.RecursionDesired = true m.RecursionDesired = true
var lastErr error var serverAddrs = dnsConfig.Servers
var success = false
var result = ""
var serverAddrs = sharedDNSConfig.Servers
{ {
var publicDNSHosts = []string{"8.8.8.8" /** Google **/, "8.8.4.4" /** Google **/} var publicDNSHosts = []string{"8.8.8.8" /** Google **/, "8.8.4.4" /** Google **/}
for _, publicDNSHost := range publicDNSHosts { for _, publicDNSHost := range publicDNSHosts {
@@ -50,32 +57,36 @@ func LookupCNAME(host string) (string, error) {
} }
var wg = &sync.WaitGroup{} var wg = &sync.WaitGroup{}
var lastErr error
var success = false
var result = ""
for _, serverAddr := range serverAddrs { for _, serverAddr := range serverAddrs {
wg.Add(1) wg.Add(1)
go func(server string) {
go func(serverAddr string) {
defer wg.Done() defer wg.Done()
r, _, err := sharedDNSClient.Exchange(m, configutils.QuoteIP(serverAddr)+":"+sharedDNSConfig.Port) r, _, err := dnsClient.Exchange(m, server+":"+dnsConfig.Port)
if err != nil { if err == nil && r != nil && r.Rcode == dns.RcodeSuccess {
for _, ans := range r.Answer {
if cname, ok := ans.(*dns.CNAME); ok {
success = true
result = cname.Target
}
}
} else if err != nil {
lastErr = err lastErr = err
return
} }
success = true
if len(r.Answer) == 0 {
return
}
result = r.Answer[0].(*dns.CNAME).Target
}(serverAddr) }(serverAddr)
} }
wg.Wait() wg.Wait()
if success { if success {
return result, nil return result, nil
} }
if lastErr != nil {
return "", lastErr
}
return "", lastErr return "", errors.New("lookup failed")
} }

View File

@@ -0,0 +1,22 @@
package apps
import (
"strconv"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
)
type AppAction struct {
actionutils.ParentAction
}
func (this *AppAction) Init() {
this.Nav("httpdns", "app", "")
}
func (this *AppAction) RunGet(params struct {
AppId int64
}) {
app := pickApp(params.AppId)
this.RedirectURL("/httpdns/apps/domains?appId=" + strconv.FormatInt(app.GetInt64("id"), 10))
}

View File

@@ -0,0 +1,55 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
"github.com/iwind/TeaGo/actions"
)
type AppSettingsAction struct {
actionutils.ParentAction
}
func (this *AppSettingsAction) Init() {
this.Nav("httpdns", "app", "")
}
func (this *AppSettingsAction) RunGet(params struct {
AppId int64
}) {
httpdnsutils.AddLeftMenu(this.Parent())
app := pickApp(params.AppId)
settings := loadAppSettings(app)
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, "please select app")
app := pickApp(params.AppId)
settings := loadAppSettings(app)
settings["appStatus"] = params.AppStatus
// SNI strategy is fixed to level2 empty.
settings["sniPolicy"] = "level2"
settings["level2Mode"] = "empty"
settings["publicSniDomain"] = ""
settings["echFallbackPolicy"] = "level2"
settings["ecsMode"] = "off"
settings["ecsIPv4Prefix"] = 24
settings["ecsIPv6Prefix"] = 56
settings["pinningMode"] = "off"
settings["sanMode"] = "off"
saveAppSettings(app.GetInt64("id"), settings)
this.Success()
}

View File

@@ -0,0 +1,23 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/actions"
)
type AppSettingsResetAESSecretAction struct {
actionutils.ParentAction
}
func (this *AppSettingsResetAESSecretAction) RunPost(params struct {
AppId int64
Must *actions.Must
CSRF *actionutils.CSRF
}) {
params.Must.Field("appId", params.AppId).Gt(0, "请选择应用")
app := pickApp(params.AppId)
resetAESSecret(app)
this.Success()
}

View File

@@ -0,0 +1,23 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/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
CSRF *actionutils.CSRF
}) {
params.Must.Field("appId", params.AppId).Gt(0, "请选择应用")
app := pickApp(params.AppId)
resetSignSecret(app)
this.Success()
}

View File

@@ -0,0 +1,27 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/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
CSRF *actionutils.CSRF
}) {
params.Must.Field("appId", params.AppId).Gt(0, "请选择应用")
app := pickApp(params.AppId)
settings := loadAppSettings(app)
settings["signEnabled"] = params.IsOn == 1
saveAppSettings(app.GetInt64("id"), settings)
this.Success()
}

View File

@@ -0,0 +1,232 @@
package apps
import (
"fmt"
"sync"
"time"
"github.com/iwind/TeaGo/maps"
)
var appSettingsStore = struct {
sync.RWMutex
data map[int64]maps.Map
}{
data: map[int64]maps.Map{},
}
func defaultAppSettings(app maps.Map) maps.Map {
signSecretPlain := randomPlainSecret("ss")
aesSecretPlain := randomPlainSecret("as")
return maps.Map{
"appId": app.GetString("appId"),
"signSecretPlain": signSecretPlain,
"signSecretMasked": maskSecret(signSecretPlain),
"signSecretUpdatedAt": "2026-02-20 12:30:00",
"aesSecretPlain": aesSecretPlain,
"aesSecretMasked": maskSecret(aesSecretPlain),
"aesSecretUpdatedAt": "2026-02-12 09:45:00",
"appStatus": app.GetBool("isOn"),
"defaultTTL": 30,
"fallbackTimeoutMs": 300,
"sniPolicy": "level2",
"level2Mode": "empty",
"publicSniDomain": "",
"echFallbackPolicy": "level2",
"ecsMode": "off",
"ecsIPv4Prefix": 24,
"ecsIPv6Prefix": 56,
"pinningMode": "off",
"sanMode": "off",
"signEnabled": true,
}
}
func cloneSettings(settings maps.Map) maps.Map {
return maps.Map{
"appId": settings.GetString("appId"),
"signSecretPlain": settings.GetString("signSecretPlain"),
"signSecretMasked": settings.GetString("signSecretMasked"),
"signSecretUpdatedAt": settings.GetString("signSecretUpdatedAt"),
"aesSecretPlain": settings.GetString("aesSecretPlain"),
"aesSecretMasked": settings.GetString("aesSecretMasked"),
"aesSecretUpdatedAt": settings.GetString("aesSecretUpdatedAt"),
"appStatus": settings.GetBool("appStatus"),
"defaultTTL": settings.GetInt("defaultTTL"),
"fallbackTimeoutMs": settings.GetInt("fallbackTimeoutMs"),
"sniPolicy": settings.GetString("sniPolicy"),
"level2Mode": settings.GetString("level2Mode"),
"publicSniDomain": settings.GetString("publicSniDomain"),
"echFallbackPolicy": settings.GetString("echFallbackPolicy"),
"ecsMode": settings.GetString("ecsMode"),
"ecsIPv4Prefix": settings.GetInt("ecsIPv4Prefix"),
"ecsIPv6Prefix": settings.GetInt("ecsIPv6Prefix"),
"pinningMode": settings.GetString("pinningMode"),
"sanMode": settings.GetString("sanMode"),
"signEnabled": settings.GetBool("signEnabled"),
}
}
func loadAppSettings(app maps.Map) maps.Map {
appId := app.GetInt64("id")
appSettingsStore.RLock()
settings, ok := appSettingsStore.data[appId]
appSettingsStore.RUnlock()
if ok {
if ensureSettingsFields(settings) {
saveAppSettings(appId, settings)
}
return cloneSettings(settings)
}
settings = defaultAppSettings(app)
saveAppSettings(appId, settings)
return cloneSettings(settings)
}
func saveAppSettings(appId int64, settings maps.Map) {
appSettingsStore.Lock()
appSettingsStore.data[appId] = cloneSettings(settings)
appSettingsStore.Unlock()
}
func resetSignSecret(app maps.Map) maps.Map {
settings := loadAppSettings(app)
signSecretPlain := randomPlainSecret("ss")
settings["signSecretPlain"] = signSecretPlain
settings["signSecretMasked"] = maskSecret(signSecretPlain)
settings["signSecretUpdatedAt"] = nowDateTime()
saveAppSettings(app.GetInt64("id"), settings)
return settings
}
func resetAESSecret(app maps.Map) maps.Map {
settings := loadAppSettings(app)
aesSecretPlain := randomPlainSecret("as")
settings["aesSecretPlain"] = aesSecretPlain
settings["aesSecretMasked"] = maskSecret(aesSecretPlain)
settings["aesSecretUpdatedAt"] = nowDateTime()
saveAppSettings(app.GetInt64("id"), settings)
return settings
}
func nowDateTime() string {
return time.Now().Format("2006-01-02 15:04:05")
}
func randomPlainSecret(prefix string) string {
suffix := time.Now().UnixNano() & 0xffff
return fmt.Sprintf("%s_%016x", prefix, suffix)
}
func maskSecret(secret string) string {
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 ensureSettingsFields(settings maps.Map) bool {
changed := false
signSecretPlain := settings.GetString("signSecretPlain")
if len(signSecretPlain) == 0 {
signSecretPlain = randomPlainSecret("ss")
settings["signSecretPlain"] = signSecretPlain
changed = true
}
if len(settings.GetString("signSecretMasked")) == 0 {
settings["signSecretMasked"] = maskSecret(signSecretPlain)
changed = true
}
if len(settings.GetString("signSecretUpdatedAt")) == 0 {
settings["signSecretUpdatedAt"] = nowDateTime()
changed = true
}
aesSecretPlain := settings.GetString("aesSecretPlain")
if len(aesSecretPlain) == 0 {
aesSecretPlain = randomPlainSecret("as")
settings["aesSecretPlain"] = aesSecretPlain
changed = true
}
if len(settings.GetString("aesSecretMasked")) == 0 {
settings["aesSecretMasked"] = maskSecret(aesSecretPlain)
changed = true
}
if len(settings.GetString("aesSecretUpdatedAt")) == 0 {
settings["aesSecretUpdatedAt"] = nowDateTime()
changed = true
}
if len(settings.GetString("sniPolicy")) == 0 {
settings["sniPolicy"] = "level2"
changed = true
} else if settings.GetString("sniPolicy") != "level2" {
settings["sniPolicy"] = "level2"
changed = true
}
if settings.GetString("level2Mode") != "empty" {
settings["level2Mode"] = "empty"
changed = true
}
if len(settings.GetString("publicSniDomain")) > 0 {
settings["publicSniDomain"] = ""
changed = true
}
if len(settings.GetString("echFallbackPolicy")) == 0 {
settings["echFallbackPolicy"] = "level2"
changed = true
} else if settings.GetString("echFallbackPolicy") != "level2" {
settings["echFallbackPolicy"] = "level2"
changed = true
}
if settings.GetString("ecsMode") != "off" {
settings["ecsMode"] = "off"
changed = true
}
if settings.GetInt("ecsIPv4Prefix") <= 0 {
settings["ecsIPv4Prefix"] = 24
changed = true
}
if settings.GetInt("ecsIPv6Prefix") <= 0 {
settings["ecsIPv6Prefix"] = 56
changed = true
}
if settings.GetString("pinningMode") != "off" {
settings["pinningMode"] = "off"
changed = true
}
if settings.GetString("sanMode") != "off" {
settings["sanMode"] = "off"
changed = true
}
return changed
}
// LoadAppSettingsByAppID exposes app settings for other httpdns sub-modules
// such as sandbox mock responses.
func LoadAppSettingsByAppID(appID string) maps.Map {
for _, app := range mockApps() {
if app.GetString("appId") == appID {
return loadAppSettings(app)
}
}
return nil
}

View File

@@ -0,0 +1,49 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/policies"
"github.com/iwind/TeaGo/actions"
"github.com/iwind/TeaGo/maps"
)
type CreatePopupAction struct {
actionutils.ParentAction
}
func (this *CreatePopupAction) Init() {
this.Nav("", "", "")
}
func (this *CreatePopupAction) RunGet(params struct{}) {
clusters := policies.LoadAvailableDeployClusters()
this.Data["clusters"] = clusters
defaultClusterID := policies.LoadDefaultClusterID()
if defaultClusterID <= 0 && len(clusters) > 0 {
defaultClusterID = clusters[0].GetInt64("id")
}
this.Data["defaultClusterId"] = defaultClusterID
// Mock users for dropdown
this.Data["users"] = []maps.Map{
{"id": int64(1), "name": "张三", "username": "zhangsan"},
{"id": int64(2), "name": "李四", "username": "lisi"},
{"id": int64(3), "name": "王五", "username": "wangwu"},
}
this.Show()
}
func (this *CreatePopupAction) RunPost(params struct {
Name string
ClusterId int64
UserId int64
Must *actions.Must
CSRF *actionutils.CSRF
}) {
params.Must.Field("name", params.Name).Require("请输入应用名称")
params.Must.Field("clusterId", params.ClusterId).Gt(0, "请选择所属集群")
this.Success()
}

View File

@@ -0,0 +1,61 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
"github.com/iwind/TeaGo/maps"
)
type CustomRecordsAction struct {
actionutils.ParentAction
}
func (this *CustomRecordsAction) Init() {
this.Nav("httpdns", "app", "")
}
func (this *CustomRecordsAction) RunGet(params struct {
AppId int64
DomainId int64
}) {
httpdnsutils.AddLeftMenu(this.Parent())
app := pickApp(params.AppId)
domains := mockDomains(app.GetInt64("id"))
domain := pickDomainFromDomains(domains, params.DomainId)
domainName := domain.GetString("name")
records := make([]maps.Map, 0)
for _, record := range loadCustomRecords(app.GetInt64("id")) {
if len(domainName) > 0 && record.GetString("domain") != domainName {
continue
}
records = append(records, record)
}
for _, record := range records {
record["lineText"] = buildLineText(record)
record["paramsText"] = buildParamsText(record)
record["recordValueText"] = buildRecordValueText(record)
}
this.Data["app"] = app
this.Data["domain"] = domain
this.Data["records"] = records
this.Show()
}
func pickDomainFromDomains(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]
}

View File

@@ -0,0 +1,344 @@
package apps
import (
"encoding/json"
"fmt"
"strings"
"github.com/TeaOSLab/EdgeAdmin/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 := pickApp(params.AppId)
this.Data["app"] = app
domains := mockDomains(app.GetInt64("id"))
domain := pickDomainFromDomains(domains, params.DomainId)
this.Data["domain"] = domain
record := maps.Map{
"id": int64(0),
"domain": domain.GetString("name"),
"lineScope": "china",
"lineCarrier": "默认",
"lineRegion": "默认",
"lineProvince": "默认",
"lineContinent": "默认",
"lineCountry": "默认",
"ruleName": "",
"weightEnabled": false,
"ttl": 30,
"isOn": true,
"sdnsParamsJson": "[]",
"recordItemsJson": `[{"type":"A","value":"","weight":100}]`,
}
if params.RecordId > 0 {
existing := findCustomRecord(app.GetInt64("id"), params.RecordId)
if len(existing) > 0 {
record["id"] = existing.GetInt64("id")
if len(record.GetString("domain")) == 0 {
record["domain"] = existing.GetString("domain")
}
record["lineScope"] = strings.TrimSpace(existing.GetString("lineScope"))
record["lineCarrier"] = strings.TrimSpace(existing.GetString("lineCarrier"))
record["lineRegion"] = strings.TrimSpace(existing.GetString("lineRegion"))
record["lineProvince"] = strings.TrimSpace(existing.GetString("lineProvince"))
record["lineContinent"] = strings.TrimSpace(existing.GetString("lineContinent"))
record["lineCountry"] = strings.TrimSpace(existing.GetString("lineCountry"))
record["ruleName"] = existing.GetString("ruleName")
record["weightEnabled"] = existing.GetBool("weightEnabled")
record["ttl"] = existing.GetInt("ttl")
record["isOn"] = existing.GetBool("isOn")
sdnsParams, _ := existing["sdnsParams"].([]maps.Map)
record["sdnsParamsJson"] = marshalJSON(sdnsParams, "[]")
recordItems := make([]maps.Map, 0)
recordType := strings.ToUpper(strings.TrimSpace(existing.GetString("recordType")))
values, _ := existing["recordValues"].([]maps.Map)
for _, item := range values {
itemType := strings.ToUpper(strings.TrimSpace(item.GetString("type")))
if len(itemType) == 0 {
itemType = recordType
}
if itemType != "A" && itemType != "AAAA" {
itemType = "A"
}
recordItems = append(recordItems, maps.Map{
"type": itemType,
"value": strings.TrimSpace(item.GetString("value")),
"weight": item.GetInt("weight"),
})
}
if len(recordItems) == 0 {
recordItems = append(recordItems, maps.Map{
"type": "A",
"value": "",
"weight": 100,
})
}
record["recordItemsJson"] = marshalJSON(recordItems, "[]")
}
}
if record.GetString("lineScope") != "china" && record.GetString("lineScope") != "overseas" {
if len(strings.TrimSpace(record.GetString("lineContinent"))) > 0 || len(strings.TrimSpace(record.GetString("lineCountry"))) > 0 {
record["lineScope"] = "overseas"
} else {
record["lineScope"] = "china"
}
}
if len(record.GetString("lineCarrier")) == 0 {
record["lineCarrier"] = "默认"
}
if len(record.GetString("lineRegion")) == 0 {
record["lineRegion"] = "默认"
}
if len(record.GetString("lineProvince")) == 0 {
record["lineProvince"] = "默认"
}
if len(record.GetString("lineContinent")) == 0 {
record["lineContinent"] = "默认"
}
if len(record.GetString("lineCountry")) == 0 {
record["lineCountry"] = "默认"
}
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
SDNSParamsJSON string
RecordItemsJSON string
WeightEnabled bool
TTL int
IsOn bool
Must *actions.Must
CSRF *actionutils.CSRF
}) {
params.Must.Field("appId", params.AppId).Gt(0, "please select app")
params.Domain = strings.TrimSpace(params.Domain)
params.LineScope = strings.ToLower(strings.TrimSpace(params.LineScope))
params.RuleName = strings.TrimSpace(params.RuleName)
params.SDNSParamsJSON = strings.TrimSpace(params.SDNSParamsJSON)
params.RecordItemsJSON = strings.TrimSpace(params.RecordItemsJSON)
domain := maps.Map{}
if params.DomainId > 0 {
domain = pickDomainFromDomains(mockDomains(params.AppId), params.DomainId)
}
if len(domain) > 0 {
params.Domain = strings.TrimSpace(domain.GetString("name"))
}
if len(params.Domain) == 0 {
this.Fail("please select domain")
return
}
if params.LineScope != "china" && params.LineScope != "overseas" {
params.LineScope = "china"
}
if len(params.RuleName) == 0 {
this.Fail("please input rule name")
return
}
if params.TTL <= 0 || params.TTL > 86400 {
this.Fail("ttl should be in 1-86400")
return
}
sdnsParams, err := parseSDNSParamsJSON(params.SDNSParamsJSON)
if err != nil {
this.Fail(err.Error())
return
}
if len(sdnsParams) > 10 {
this.Fail("sdns params should be <= 10")
return
}
recordValues, err := parseRecordItemsJSON(params.RecordItemsJSON, params.WeightEnabled)
if err != nil {
this.Fail(err.Error())
return
}
if len(recordValues) == 0 {
this.Fail("please input record values")
return
}
if len(recordValues) > 10 {
this.Fail("record values should be <= 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 = ""
}
recordType := recordValues[0].GetString("type")
if len(recordType) == 0 {
recordType = "A"
}
saveCustomRecord(params.AppId, maps.Map{
"id": params.RecordId,
"domain": params.Domain,
"lineScope": params.LineScope,
"lineCarrier": lineCarrier,
"lineRegion": lineRegion,
"lineProvince": lineProvince,
"lineContinent": lineContinent,
"lineCountry": lineCountry,
"ruleName": params.RuleName,
"sdnsParams": sdnsParams,
"recordType": recordType,
"recordValues": recordValues,
"weightEnabled": params.WeightEnabled,
"ttl": params.TTL,
"isOn": params.IsOn,
})
this.Success()
}
func parseSDNSParamsJSON(raw string) ([]maps.Map, error) {
if len(raw) == 0 {
return []maps.Map{}, nil
}
list := []maps.Map{}
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return nil, fmt.Errorf("sdns params json is invalid")
}
result := make([]maps.Map, 0, len(list))
for _, item := range list {
name := strings.TrimSpace(item.GetString("name"))
value := strings.TrimSpace(item.GetString("value"))
if len(name) == 0 && len(value) == 0 {
continue
}
if len(name) < 2 || len(name) > 64 {
return nil, fmt.Errorf("sdns param name length should be in 2-64")
}
if len(value) < 1 || len(value) > 64 {
return nil, fmt.Errorf("sdns param value length should be in 1-64")
}
result = append(result, maps.Map{
"name": name,
"value": value,
})
}
return result, nil
}
func parseRecordItemsJSON(raw string, weightEnabled bool) ([]maps.Map, error) {
if len(raw) == 0 {
return []maps.Map{}, nil
}
list := []maps.Map{}
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return nil, fmt.Errorf("record items json is invalid")
}
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("record type should be A or AAAA")
}
if len(recordValue) == 0 {
return nil, fmt.Errorf("record value should not be empty")
}
weight := item.GetInt("weight")
if !weightEnabled {
weight = 100
}
if weight < 1 || weight > 100 {
return nil, fmt.Errorf("weight should be in 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,17 @@
package apps
import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
type CustomRecordsDeleteAction struct {
actionutils.ParentAction
}
func (this *CustomRecordsDeleteAction) RunPost(params struct {
AppId int64
RecordId int64
}) {
if params.AppId > 0 && params.RecordId > 0 {
deleteCustomRecord(params.AppId, params.RecordId)
}
this.Success()
}

View File

@@ -0,0 +1,18 @@
package apps
import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
type CustomRecordsToggleAction struct {
actionutils.ParentAction
}
func (this *CustomRecordsToggleAction) RunPost(params struct {
AppId int64
RecordId int64
IsOn bool
}) {
if params.AppId > 0 && params.RecordId > 0 {
toggleCustomRecord(params.AppId, params.RecordId, params.IsOn)
}
this.Success()
}

View File

@@ -0,0 +1,264 @@
package apps
import (
"strconv"
"strings"
"sync"
"time"
"github.com/iwind/TeaGo/maps"
)
var customRecordStore = struct {
sync.RWMutex
nextID int64
data map[int64][]maps.Map
}{
nextID: 1000,
data: map[int64][]maps.Map{
1: {
{
"id": int64(1001),
"domain": "api.business.com",
"lineScope": "china",
"lineCarrier": "电信",
"lineRegion": "华东",
"lineProvince": "上海",
"ruleName": "上海电信灰度-v2",
"sdnsParams": []maps.Map{
{
"name": "app_ver",
"value": "2.3.1",
},
},
"recordType": "A",
"recordValues": []maps.Map{{"type": "A", "value": "1.1.1.10", "weight": 100}},
"weightEnabled": false,
"ttl": 30,
"isOn": true,
"updatedAt": "2026-02-23 10:20:00",
},
},
},
}
func loadCustomRecords(appID int64) []maps.Map {
customRecordStore.RLock()
defer customRecordStore.RUnlock()
records := customRecordStore.data[appID]
result := make([]maps.Map, 0, len(records))
for _, record := range records {
result = append(result, cloneCustomRecord(record))
}
return result
}
func countCustomRecordsByDomain(appID int64, domain string) int {
domain = strings.ToLower(strings.TrimSpace(domain))
if len(domain) == 0 {
return 0
}
customRecordStore.RLock()
defer customRecordStore.RUnlock()
count := 0
for _, record := range customRecordStore.data[appID] {
if strings.ToLower(strings.TrimSpace(record.GetString("domain"))) == domain {
count++
}
}
return count
}
func findCustomRecord(appID int64, recordID int64) maps.Map {
for _, record := range loadCustomRecords(appID) {
if record.GetInt64("id") == recordID {
return record
}
}
return maps.Map{}
}
func saveCustomRecord(appID int64, record maps.Map) maps.Map {
customRecordStore.Lock()
defer customRecordStore.Unlock()
if appID <= 0 {
return maps.Map{}
}
record = cloneCustomRecord(record)
recordID := record.GetInt64("id")
if recordID <= 0 {
customRecordStore.nextID++
recordID = customRecordStore.nextID
record["id"] = recordID
}
record["updatedAt"] = nowCustomRecordTime()
records := customRecordStore.data[appID]
found := false
for i, oldRecord := range records {
if oldRecord.GetInt64("id") == recordID {
records[i] = cloneCustomRecord(record)
found = true
break
}
}
if !found {
records = append(records, cloneCustomRecord(record))
}
customRecordStore.data[appID] = records
return cloneCustomRecord(record)
}
func deleteCustomRecord(appID int64, recordID int64) {
customRecordStore.Lock()
defer customRecordStore.Unlock()
records := customRecordStore.data[appID]
if len(records) == 0 {
return
}
filtered := make([]maps.Map, 0, len(records))
for _, record := range records {
if record.GetInt64("id") == recordID {
continue
}
filtered = append(filtered, record)
}
customRecordStore.data[appID] = filtered
}
func toggleCustomRecord(appID int64, recordID int64, isOn bool) {
customRecordStore.Lock()
defer customRecordStore.Unlock()
records := customRecordStore.data[appID]
for i, record := range records {
if record.GetInt64("id") == recordID {
record["isOn"] = isOn
record["updatedAt"] = nowCustomRecordTime()
records[i] = record
break
}
}
customRecordStore.data[appID] = records
}
func cloneCustomRecord(src maps.Map) maps.Map {
dst := maps.Map{}
for k, v := range src {
switch k {
case "sdnsParams", "recordValues":
if list, ok := v.([]maps.Map); ok {
cloned := make([]maps.Map, 0, len(list))
for _, item := range list {
m := maps.Map{}
for k2, v2 := range item {
m[k2] = v2
}
cloned = append(cloned, m)
}
dst[k] = cloned
} else {
dst[k] = []maps.Map{}
}
default:
dst[k] = v
}
}
return dst
}
func nowCustomRecordTime() string {
return time.Now().Format("2006-01-02 15:04:05")
}
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 buildParamsText(record maps.Map) string {
params, ok := record["sdnsParams"].([]maps.Map)
if !ok || len(params) == 0 {
return "-"
}
parts := make([]string, 0, len(params))
for _, param := range params {
name := strings.TrimSpace(param.GetString("name"))
value := strings.TrimSpace(param.GetString("value"))
if len(name) == 0 || len(value) == 0 {
continue
}
parts = append(parts, name+"="+value)
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, "; ")
}
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")) + ")"
} else {
// no extra suffix
}
parts = append(parts, part)
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, ", ")
}

View File

@@ -0,0 +1,31 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type DomainsAction struct {
actionutils.ParentAction
}
func (this *DomainsAction) Init() {
this.Nav("httpdns", "app", "")
}
func (this *DomainsAction) RunGet(params struct {
AppId int64
}) {
httpdnsutils.AddLeftMenu(this.Parent())
app := pickApp(params.AppId)
domains := mockDomains(app.GetInt64("id"))
for _, domain := range domains {
domainName := domain.GetString("name")
domain["customRecordCount"] = countCustomRecordsByDomain(app.GetInt64("id"), domainName)
}
this.Data["app"] = app
this.Data["domains"] = domains
this.Show()
}

View File

@@ -0,0 +1,32 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/actions"
)
type DomainsCreatePopupAction struct {
actionutils.ParentAction
}
func (this *DomainsCreatePopupAction) Init() {
this.Nav("", "", "")
}
func (this *DomainsCreatePopupAction) RunGet(params struct {
AppId int64
}) {
this.Data["app"] = pickApp(params.AppId)
this.Show()
}
func (this *DomainsCreatePopupAction) RunPost(params struct {
AppId int64
Domain string
Must *actions.Must
CSRF *actionutils.CSRF
}) {
params.Must.Field("domain", params.Domain).Require("please input domain")
this.Success()
}

View File

@@ -0,0 +1,14 @@
package apps
import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
type DomainsDeleteAction struct {
actionutils.ParentAction
}
func (this *DomainsDeleteAction) RunPost(params struct {
DomainId int64
}) {
_ = params.DomainId
this.Success()
}

View File

@@ -0,0 +1,23 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) Init() {
this.Nav("httpdns", "app", "")
}
func (this *IndexAction) RunGet(params struct {
Keyword string
}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["keyword"] = params.Keyword
this.Data["apps"] = filterApps(params.Keyword, "", "", "")
this.Show()
}

View File

@@ -0,0 +1,32 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/internal/configloaders"
"github.com/TeaOSLab/EdgeAdmin/internal/web/helpers"
"github.com/iwind/TeaGo"
)
func init() {
TeaGo.BeforeStart(func(server *TeaGo.Server) {
server.
Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)).
Data("teaMenu", "httpdns").
Data("teaSubMenu", "app").
Prefix("/httpdns/apps").
Get("", new(IndexAction)).
Get("/app", new(AppAction)).
GetPost("/app/settings", new(AppSettingsAction)).
Post("/app/settings/toggleSignEnabled", new(AppSettingsToggleSignEnabledAction)).
Post("/app/settings/resetSignSecret", new(AppSettingsResetSignSecretAction)).
Post("/app/settings/resetAESSecret", new(AppSettingsResetAESSecretAction)).
Get("/domains", new(DomainsAction)).
Get("/customRecords", new(CustomRecordsAction)).
GetPost("/createPopup", new(CreatePopupAction)).
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,128 @@
package apps
import (
"strings"
"github.com/iwind/TeaGo/maps"
)
func mockApps() []maps.Map {
return []maps.Map{
{
"id": int64(1),
"name": "主站移动业务",
"appId": "ab12xc34s2",
"clusterId": int64(1),
"domainCount": 3,
"isOn": true,
"authStatus": "enabled",
"ecsMode": "auto",
"pinningMode": "report",
"sanMode": "strict",
"riskLevel": "medium",
"riskSummary": "Pinning 处于观察模式",
"secretVersion": "v2026.02.20",
},
{
"id": int64(2),
"name": "视频网关业务",
"appId": "vd8992ksm1",
"clusterId": int64(2),
"domainCount": 1,
"isOn": true,
"authStatus": "enabled",
"ecsMode": "custom",
"pinningMode": "enforce",
"sanMode": "strict",
"riskLevel": "low",
"riskSummary": "已启用强校验",
"secretVersion": "v2026.02.18",
},
{
"id": int64(3),
"name": "海外灰度测试",
"appId": "ov7711hkq9",
"clusterId": int64(1),
"domainCount": 2,
"isOn": false,
"authStatus": "disabled",
"ecsMode": "off",
"pinningMode": "off",
"sanMode": "report",
"riskLevel": "high",
"riskSummary": "应用关闭且证书策略偏弱",
"secretVersion": "v2026.01.30",
},
}
}
func filterApps(keyword string, riskLevel string, ecsMode string, pinningMode string) []maps.Map {
all := mockApps()
if len(keyword) == 0 && len(riskLevel) == 0 && len(ecsMode) == 0 && len(pinningMode) == 0 {
return all
}
keyword = strings.ToLower(strings.TrimSpace(keyword))
result := make([]maps.Map, 0)
for _, app := range all {
if len(keyword) > 0 {
name := strings.ToLower(app.GetString("name"))
appID := strings.ToLower(app.GetString("appId"))
if !strings.Contains(name, keyword) && !strings.Contains(appID, keyword) {
continue
}
}
if len(riskLevel) > 0 && app.GetString("riskLevel") != riskLevel {
continue
}
if len(ecsMode) > 0 && app.GetString("ecsMode") != ecsMode {
continue
}
if len(pinningMode) > 0 && app.GetString("pinningMode") != pinningMode {
continue
}
result = append(result, app)
}
return result
}
func pickApp(appID int64) maps.Map {
apps := mockApps()
if appID <= 0 {
return apps[0]
}
for _, app := range apps {
if app.GetInt64("id") == appID {
return app
}
}
return apps[0]
}
func mockDomains(appID int64) []maps.Map {
_ = appID
return []maps.Map{
{
"id": int64(101),
"name": "api.business.com",
},
{
"id": int64(102),
"name": "payment.business.com",
},
}
}
func pickDomain(domainID int64) maps.Map {
domains := mockDomains(0)
if domainID <= 0 {
return domains[0]
}
for _, domain := range domains {
if domain.GetInt64("id") == domainID {
return domain
}
}
return domains[0]
}

View File

@@ -0,0 +1,85 @@
package apps
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
"github.com/iwind/TeaGo/actions"
"github.com/iwind/TeaGo/maps"
)
type PoliciesAction struct {
actionutils.ParentAction
}
func (this *PoliciesAction) Init() {
this.Nav("httpdns", "app", "")
}
func (this *PoliciesAction) RunGet(params struct{}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["policies"] = loadGlobalPolicies()
this.Show()
}
func (this *PoliciesAction) RunPost(params struct {
DefaultTTL int
DefaultSniPolicy string
DefaultFallbackMs int
ECSMode string
ECSIPv4Prefix int
ECSIPv6Prefix int
PinningMode string
SANMode string
Must *actions.Must
CSRF *actionutils.CSRF
}) {
params.Must.Field("defaultTTL", params.DefaultTTL).Gt(0, "默认 TTL 需要大于 0")
params.Must.Field("defaultFallbackMs", params.DefaultFallbackMs).Gt(0, "默认超时需要大于 0")
if params.DefaultTTL > 86400 {
this.Fail("默认 TTL 不能超过 86400 秒")
return
}
if params.DefaultFallbackMs > 10000 {
this.Fail("默认超时不能超过 10000 毫秒")
return
}
if params.DefaultSniPolicy != "level1" && params.DefaultSniPolicy != "level2" && params.DefaultSniPolicy != "level3" {
this.Fail("默认 SNI 等级不正确")
return
}
if params.ECSMode != "off" && params.ECSMode != "auto" && params.ECSMode != "custom" {
this.Fail("ECS 模式不正确")
return
}
if params.ECSIPv4Prefix < 0 || params.ECSIPv4Prefix > 32 {
this.Fail("IPv4 掩码范围是 0-32")
return
}
if params.ECSIPv6Prefix < 0 || params.ECSIPv6Prefix > 128 {
this.Fail("IPv6 掩码范围是 0-128")
return
}
if params.PinningMode != "off" && params.PinningMode != "report" && params.PinningMode != "enforce" {
this.Fail("Pinning 策略不正确")
return
}
if params.SANMode != "off" && params.SANMode != "report" && params.SANMode != "strict" {
this.Fail("SAN 策略不正确")
return
}
saveGlobalPolicies(maps.Map{
"defaultTTL": params.DefaultTTL,
"defaultSniPolicy": params.DefaultSniPolicy,
"defaultFallbackMs": params.DefaultFallbackMs,
"ecsMode": params.ECSMode,
"ecsIPv4Prefix": params.ECSIPv4Prefix,
"ecsIPv6Prefix": params.ECSIPv6Prefix,
"pinningMode": params.PinningMode,
"sanMode": params.SANMode,
})
this.Success()
}

View File

@@ -0,0 +1,54 @@
package apps
import (
"sync"
"github.com/iwind/TeaGo/maps"
)
var globalPoliciesStore = struct {
sync.RWMutex
data maps.Map
}{
data: maps.Map{
"defaultTTL": 30,
"defaultSniPolicy": "level2",
"defaultFallbackMs": 300,
"ecsMode": "auto",
"ecsIPv4Prefix": 24,
"ecsIPv6Prefix": 56,
"pinningMode": "report",
"sanMode": "strict",
},
}
func loadGlobalPolicies() maps.Map {
globalPoliciesStore.RLock()
defer globalPoliciesStore.RUnlock()
return maps.Map{
"defaultTTL": globalPoliciesStore.data.GetInt("defaultTTL"),
"defaultSniPolicy": globalPoliciesStore.data.GetString("defaultSniPolicy"),
"defaultFallbackMs": globalPoliciesStore.data.GetInt("defaultFallbackMs"),
"ecsMode": globalPoliciesStore.data.GetString("ecsMode"),
"ecsIPv4Prefix": globalPoliciesStore.data.GetInt("ecsIPv4Prefix"),
"ecsIPv6Prefix": globalPoliciesStore.data.GetInt("ecsIPv6Prefix"),
"pinningMode": globalPoliciesStore.data.GetString("pinningMode"),
"sanMode": globalPoliciesStore.data.GetString("sanMode"),
}
}
func saveGlobalPolicies(policies maps.Map) {
globalPoliciesStore.Lock()
globalPoliciesStore.data = maps.Map{
"defaultTTL": policies.GetInt("defaultTTL"),
"defaultSniPolicy": policies.GetString("defaultSniPolicy"),
"defaultFallbackMs": policies.GetInt("defaultFallbackMs"),
"ecsMode": policies.GetString("ecsMode"),
"ecsIPv4Prefix": policies.GetInt("ecsIPv4Prefix"),
"ecsIPv6Prefix": policies.GetInt("ecsIPv6Prefix"),
"pinningMode": policies.GetString("pinningMode"),
"sanMode": policies.GetString("sanMode"),
}
globalPoliciesStore.Unlock()
}

View File

@@ -0,0 +1,21 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/policies"
)
type CertsAction struct {
actionutils.ParentAction
}
func (this *CertsAction) Init() {
this.Nav("httpdns", "cluster", "certs")
}
func (this *CertsAction) RunGet(params struct{}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["certs"] = policies.LoadPublicSNICertificates()
this.Show()
}

View File

@@ -0,0 +1,10 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
type CheckPortsAction struct { actionutils.ParentAction }
func (this *CheckPortsAction) RunPost(params struct{ NodeId int64 }) {
this.Data["results"] = []maps.Map{}
this.Success()
}

View File

@@ -0,0 +1,34 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type ClusterAction struct {
actionutils.ParentAction
}
func (this *ClusterAction) Init() {
this.Nav("httpdns", "cluster", "index")
}
func (this *ClusterAction) RunGet(params struct {
ClusterId int64
InstalledState int
ActiveState int
Keyword string
}) {
httpdnsutils.AddLeftMenu(this.Parent())
cluster := pickCluster(params.ClusterId)
this.Data["clusterId"] = params.ClusterId
this.Data["cluster"] = cluster
this.Data["installState"] = params.InstalledState
this.Data["activeState"] = params.ActiveState
this.Data["keyword"] = params.Keyword
nodes := mockNodes(params.ClusterId, params.InstalledState, params.ActiveState, params.Keyword)
this.Data["nodes"] = nodes
this.Data["hasNodes"] = len(nodes) > 0
this.Data["page"] = ""
this.Show()
}

View File

@@ -0,0 +1,68 @@
package node
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) Init() {
this.Nav("", "node", "node")
this.SecondMenu("nodes")
}
func (this *IndexAction) RunGet(params struct {
ClusterId int64
NodeId int64
}) {
this.Data["clusterId"] = params.ClusterId
this.Data["nodeId"] = params.NodeId
this.Data["currentCluster"] = maps.Map{"id": params.ClusterId, "name": "Mock Cluster"}
this.Data["nodeDatetime"] = "2026-02-22 12:00:00"
this.Data["nodeTimeDiff"] = 0
this.Data["shouldUpgrade"] = false
this.Data["newVersion"] = ""
this.Data["node"] = maps.Map{
"id": params.NodeId,
"name": "Mock HTTPDNS Node",
"ipAddresses": []maps.Map{{"ip": "100.200.100.200", "name": "Public IP", "canAccess": true, "isOn": true, "isUp": true}},
"cluster": maps.Map{"id": params.ClusterId, "name": "Mock Cluster", "installDir": "/opt/edge-httpdns"},
"installDir": "/opt/edge-httpdns",
"isInstalled": true,
"uniqueId": "m-1234567890",
"secret": "mock-secret-key",
"isOn": true,
"isUp": true,
"apiNodeAddrs": []string{"192.168.1.100:8001"},
"login": nil,
"status": maps.Map{
"isActive": true,
"updatedAt": 1670000000,
"hostname": "node-01.local",
"cpuUsage": 0.15,
"cpuUsageText": "15.00%",
"memUsage": 0.45,
"memUsageText": "45.00%",
"connectionCount": 100,
"buildVersion": "1.0.0",
"cpuPhysicalCount": 4,
"cpuLogicalCount": 8,
"load1m": "0.50",
"load5m": "0.60",
"load15m": "0.70",
"cacheTotalDiskSize": "10G",
"cacheTotalMemorySize": "2G",
"exePath": "/opt/edge-httpdns/bin/edge-httpdns",
"apiSuccessPercent": 100.0,
"apiAvgCostSeconds": 0.05,
},
}
this.Show()
}

View File

@@ -0,0 +1,41 @@
package node
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
type InstallAction struct {
actionutils.ParentAction
}
func (this *InstallAction) Init() {
this.Nav("", "node", "install")
this.SecondMenu("nodes")
}
func (this *InstallAction) RunGet(params struct{ ClusterId int64; NodeId int64 }) {
this.Data["clusterId"] = params.ClusterId
this.Data["nodeId"] = params.NodeId
this.Data["currentCluster"] = maps.Map{"id": params.ClusterId, "name": "Mock Cluster"}
this.Data["apiEndpoints"] = "\"http://127.0.0.1:7788\""
this.Data["sshAddr"] = "192.168.1.100:22"
this.Data["node"] = maps.Map{
"id": params.NodeId,
"name": "Mock Node",
"isInstalled": false,
"uniqueId": "m-1234567890",
"secret": "mock-secret-key",
"installDir": "/opt/edge-httpdns",
"cluster": maps.Map{"installDir": "/opt/edge-httpdns"},
}
this.Data["installStatus"] = nil
this.Show()
}
func (this *InstallAction) RunPost(params struct{ NodeId int64 }) {
this.Success()
}

View File

@@ -0,0 +1,31 @@
package node
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
type LogsAction struct {
actionutils.ParentAction
}
func (this *LogsAction) Init() {
this.Nav("", "node", "log")
this.SecondMenu("nodes")
}
func (this *LogsAction) RunGet(params struct{ ClusterId int64; NodeId int64 }) {
this.Data["clusterId"] = params.ClusterId
this.Data["nodeId"] = params.NodeId
this.Data["currentCluster"] = maps.Map{"id": params.ClusterId, "name": "Mock Cluster"}
this.Data["dayFrom"] = ""
this.Data["dayTo"] = ""
this.Data["keyword"] = ""
this.Data["level"] = ""
this.Data["logs"] = []maps.Map{}
this.Data["page"] = ""
this.Data["node"] = maps.Map{"id": params.NodeId, "name": "Mock Node"}
this.Show()
}

View File

@@ -0,0 +1,13 @@
package node
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
)
type StartAction struct {
actionutils.ParentAction
}
func (this *StartAction) RunPost(params struct{ NodeId int64 }) {
this.Success()
}

View File

@@ -0,0 +1,27 @@
package node
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
type StatusAction struct {
actionutils.ParentAction
}
func (this *StatusAction) Init() {
this.Nav("", "node", "status")
this.SecondMenu("nodes")
}
func (this *StatusAction) RunPost(params struct{ NodeId int64 }) {
this.Data["installStatus"] = maps.Map{
"isRunning": false,
"isFinished": true,
"isOk": true,
"error": "",
"errorCode": "",
}
this.Data["isInstalled"] = true
this.Success()
}

View File

@@ -0,0 +1,13 @@
package node
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
)
type StopAction struct {
actionutils.ParentAction
}
func (this *StopAction) RunPost(params struct{ NodeId int64 }) {
this.Success()
}

View File

@@ -0,0 +1,41 @@
package node
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
type UpdateAction struct {
actionutils.ParentAction
}
func (this *UpdateAction) Init() {
this.Nav("", "node", "update")
this.SecondMenu("nodes")
}
func (this *UpdateAction) RunGet(params struct{ ClusterId int64; NodeId int64 }) {
this.Data["clusterId"] = params.ClusterId
this.Data["nodeId"] = params.NodeId
this.Data["currentCluster"] = maps.Map{"id": params.ClusterId, "name": "Mock Cluster"}
this.Data["clusters"] = []maps.Map{{"id": params.ClusterId, "name": "Mock Cluster"}}
this.Data["loginId"] = 0
this.Data["sshHost"] = "192.168.1.100"
this.Data["sshPort"] = 22
this.Data["grant"] = nil
this.Data["apiNodeAddrs"] = []string{}
this.Data["node"] = maps.Map{
"id": params.NodeId,
"name": "Mock Node",
"isOn": true,
"ipAddresses": []maps.Map{},
}
this.Show()
}
func (this *UpdateAction) RunPost(params struct{ NodeId int64 }) {
this.Success()
}

View File

@@ -0,0 +1,13 @@
package node
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
)
type UpdateInstallStatusAction struct {
actionutils.ParentAction
}
func (this *UpdateInstallStatusAction) RunPost(params struct{ NodeId int64 }) {
this.Success()
}

View File

@@ -0,0 +1,49 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type ClusterSettingsAction struct {
actionutils.ParentAction
}
func (this *ClusterSettingsAction) Init() {
this.Nav("httpdns", "cluster", "settings")
}
func (this *ClusterSettingsAction) RunGet(params struct {
ClusterId int64
}) {
httpdnsutils.AddLeftMenu(this.Parent())
cluster := pickCluster(params.ClusterId)
installDir := cluster.GetString("installDir")
if len(installDir) == 0 {
installDir = "/opt/edge-httpdns"
}
this.Data["cluster"] = cluster
this.Data["settings"] = map[string]interface{}{
"region": cluster.GetString("region"),
"gatewayDomain": cluster.GetString("gatewayDomain"),
"cacheTtl": cluster.GetInt("cacheTtl"),
"fallbackTimeout": cluster.GetInt("fallbackTimeout"),
"installDir": installDir,
"isOn": cluster.GetBool("isOn"),
}
this.Show()
}
func (this *ClusterSettingsAction) RunPost(params struct {
ClusterId int64
Name string
Region string
GatewayDomain string
CacheTtl int32
FallbackTimeout int32
InstallDir string
IsOn bool
}) {
// Mock successful save
this.Success()
}

View File

@@ -0,0 +1,19 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type CreateAction struct {
actionutils.ParentAction
}
func (this *CreateAction) Init() {
this.Nav("httpdns", "cluster", "")
}
func (this *CreateAction) RunGet(params struct{}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Show()
}

View File

@@ -0,0 +1,30 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
"github.com/iwind/TeaGo/maps"
)
type CreateNodeAction struct {
actionutils.ParentAction
}
func (this *CreateNodeAction) Init() {
this.Nav("", "node", "createNode")
this.SecondMenu("nodes")
}
func (this *CreateNodeAction) RunGet(params struct{ ClusterId int64 }) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["clusterId"] = params.ClusterId
this.Data["cluster"] = maps.Map{"id": params.ClusterId, "name": "Mock Cluster"}
this.Show()
}
func (this *CreateNodeAction) RunPost(params struct {
ClusterId int64
Name string
}) {
this.Success()
}

View File

@@ -0,0 +1,30 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type DeleteAction struct {
actionutils.ParentAction
}
func (this *DeleteAction) Init() {
this.Nav("httpdns", "cluster", "delete")
}
func (this *DeleteAction) RunGet(params struct {
ClusterId int64
}) {
httpdnsutils.AddLeftMenu(this.Parent())
cluster := pickCluster(params.ClusterId)
this.Data["cluster"] = cluster
this.Show()
}
func (this *DeleteAction) RunPost(params struct {
ClusterId int64
}) {
_ = params.ClusterId
this.Success()
}

View File

@@ -0,0 +1,6 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
)
type DeleteNodeAction struct { actionutils.ParentAction }
func (this *DeleteNodeAction) RunPost(params struct{ ClusterId int64; NodeId int64 }) { this.Success() }

View File

@@ -0,0 +1,20 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) Init() {
this.Nav("httpdns", "cluster", "")
}
func (this *IndexAction) RunGet(params struct{}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["clusters"] = mockClusters()
this.Show()
}

View File

@@ -0,0 +1,41 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/configloaders"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/clusters/cluster/node"
"github.com/TeaOSLab/EdgeAdmin/internal/web/helpers"
"github.com/iwind/TeaGo"
)
func init() {
TeaGo.BeforeStart(func(server *TeaGo.Server) {
server.
Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)).
Data("teaMenu", "httpdns").
Data("teaSubMenu", "cluster").
Prefix("/httpdns/clusters").
Get("", new(IndexAction)).
Get("/create", new(CreateAction)).
Get("/cluster", new(ClusterAction)).
GetPost("/cluster/settings", new(ClusterSettingsAction)).
GetPost("/delete", new(DeleteAction)).
// Node level
GetPost("/createNode", new(CreateNodeAction)).
Post("/deleteNode", new(DeleteNodeAction)).
Get("/upgradeRemote", new(UpgradeRemoteAction)).
GetPost("/updateNodeSSH", new(UpdateNodeSSHAction)).
Post("/checkPorts", new(CheckPortsAction)).
// Node internal pages
Prefix("/httpdns/clusters/cluster/node").
Get("", new(node.IndexAction)).
Get("/logs", new(node.LogsAction)).
GetPost("/update", new(node.UpdateAction)).
GetPost("/install", new(node.InstallAction)).
Post("/status", new(node.StatusAction)).
Post("/updateInstallStatus", new(node.UpdateInstallStatusAction)).
Post("/start", new(node.StartAction)).
Post("/stop", new(node.StopAction)).
EndAll()
})
}

View File

@@ -0,0 +1,126 @@
package clusters
import "github.com/iwind/TeaGo/maps"
func mockClusters() []maps.Map {
return []maps.Map{
{
"id": int64(1),
"name": "gateway-cn-hz",
"region": "cn-hangzhou",
"gatewayDomain": "gw-hz.httpdns.example.com",
"installDir": "/opt/edge-httpdns",
"countAllNodes": 3,
"countActiveNodes": 3,
"countApps": 5,
"cacheTtl": 30,
"fallbackTimeout": 300,
"isOn": true,
},
{
"id": int64(2),
"name": "gateway-cn-bj",
"region": "cn-beijing",
"gatewayDomain": "gw-bj.httpdns.example.com",
"installDir": "/opt/edge-httpdns",
"countAllNodes": 3,
"countActiveNodes": 2,
"countApps": 2,
"cacheTtl": 30,
"fallbackTimeout": 300,
"isOn": true,
},
}
}
func pickCluster(clusterId int64) maps.Map {
clusters := mockClusters()
if clusterId <= 0 {
return clusters[0]
}
for _, c := range clusters {
if c.GetInt64("id") == clusterId {
return c
}
}
return clusters[0]
}
func mockNodes(clusterId int64, installState int, activeState int, keyword string) []maps.Map {
_ = clusterId
return []maps.Map{
{
"id": int64(101),
"name": "45.250.184.56",
"isInstalled": true,
"isOn": true,
"isUp": true,
"installStatus": maps.Map{
"isRunning": false,
"isFinished": true,
"isOk": true,
"error": "",
},
"status": maps.Map{
"isActive": true,
"updatedAt": 1700000000,
"hostname": "node-01",
"cpuUsage": 0.0253,
"cpuUsageText": "2.53%",
"memUsage": 0.5972,
"memUsageText": "59.72%",
"load1m": 0.02,
},
"ipAddresses": []maps.Map{
{
"id": 1,
"name": "",
"ip": "45.250.184.56",
"canAccess": true,
},
},
},
{
"id": int64(102),
"name": "45.250.184.53",
"isInstalled": true,
"isOn": true,
"isUp": true,
"installStatus": maps.Map{
"isRunning": false,
"isFinished": true,
"isOk": true,
"error": "",
},
"status": maps.Map{
"isActive": true,
"updatedAt": 1700000000,
"hostname": "node-02",
"cpuUsage": 0.0039,
"cpuUsageText": "0.39%",
"memUsage": 0.0355,
"memUsageText": "3.55%",
"load1m": 0.0,
},
"ipAddresses": []maps.Map{
{
"id": 2,
"name": "",
"ip": "45.250.184.53",
"canAccess": true,
},
},
},
}
}
func mockCerts() []maps.Map {
return []maps.Map{
{
"id": int64(11),
"domain": "resolve.edge.example.com",
"issuer": "Mock CA",
"expiresAt": "2026-12-31 23:59:59",
},
}
}

View File

@@ -0,0 +1,39 @@
package clusters
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
type UpdateNodeSSHAction struct {
actionutils.ParentAction
}
func (this *UpdateNodeSSHAction) RunGet(params struct {
NodeId int64
}) {
this.Data["nodeId"] = params.NodeId
this.Data["clusterId"] = 0
this.Data["node"] = maps.Map{
"id": params.NodeId,
"name": "Mock Node",
}
this.Data["loginId"] = 0
this.Data["params"] = maps.Map{
"host": "1.2.3.4",
"port": 22,
"grantId": 0,
}
this.Data["grant"] = nil
this.Show()
}
func (this *UpdateNodeSSHAction) RunPost(params struct {
NodeId int64
LoginId int64
SshHost string
SshPort int
GrantId int64
}) {
this.Success()
}

View File

@@ -0,0 +1,16 @@
package clusters
import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
type UpgradeRemoteAction struct {
actionutils.ParentAction
}
func (this *UpgradeRemoteAction) RunGet(params struct {
NodeId int64
ClusterId int64
}) {
this.Data["nodeId"] = params.NodeId
this.Data["clusterId"] = params.ClusterId
this.Show()
}

View File

@@ -0,0 +1,28 @@
package ech
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type AuditAction struct {
actionutils.ParentAction
}
func (this *AuditAction) Init() {
this.Nav("httpdns", "ech", "")
}
func (this *AuditAction) RunGet(params struct{}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["auditLogs"] = []map[string]interface{}{
{
"id": 1,
"scope": "tenant:1001 -> domain:api.business.com -> region:cn-hz",
"operator": "admin",
"result": "success",
"createdAt": "2026-02-21 10:00:00",
},
}
this.Show()
}

View File

@@ -0,0 +1,47 @@
package ech
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) Init() {
this.Nav("httpdns", "ech", "")
}
func (this *IndexAction) RunGet(params struct{}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["health"] = map[string]interface{}{
"keySyncRate": 0.9995,
"decryptFailRate": 0.0001,
"isHealthy": true,
}
this.Data["echLogs"] = []map[string]interface{}{
{
"id": 1024,
"version": "ech-v20260221-01",
"recordType": "HTTPS(Type65)",
"publicKey": "BFCf8h5Qmock_public_key_for_ui_preview_lx9v2k7p0n",
"publishTime": "2026-02-21 00:00:00",
"syncStatus": "success",
"nodesPending": 0,
"isCurrent": true,
},
{
"id": 1023,
"version": "ech-v20260220-01",
"recordType": "TXT",
"publicKey": "BE9x3a2Qmock_prev_key_for_ui_preview_vd1n7x5k2c",
"publishTime": "2026-02-20 00:00:00",
"syncStatus": "success",
"nodesPending": 0,
"isCurrent": false,
},
}
this.Show()
}

View File

@@ -0,0 +1,21 @@
package ech
import (
"github.com/TeaOSLab/EdgeAdmin/internal/configloaders"
"github.com/TeaOSLab/EdgeAdmin/internal/web/helpers"
"github.com/iwind/TeaGo"
)
func init() {
TeaGo.BeforeStart(func(server *TeaGo.Server) {
server.
Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)).
Data("teaMenu", "httpdns").
Data("teaSubMenu", "ech").
Prefix("/httpdns/ech").
Get("", new(IndexAction)).
Get("/audit", new(AuditAction)).
GetPost("/rollbackMfaPopup", new(RollbackMfaPopupAction)).
EndAll()
})
}

View File

@@ -0,0 +1,36 @@
package ech
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/actions"
)
type RollbackMfaPopupAction struct {
actionutils.ParentAction
}
func (this *RollbackMfaPopupAction) Init() {
this.Nav("", "", "")
}
func (this *RollbackMfaPopupAction) RunGet(params struct {
LogId int64
}) {
this.Data["logId"] = params.LogId
this.Show()
}
func (this *RollbackMfaPopupAction) RunPost(params struct {
LogId int64
Reason string
OtpCode1 string
OtpCode2 string
Must *actions.Must
CSRF *actionutils.CSRF
}) {
params.Must.Field("reason", params.Reason).Require("please input rollback reason")
params.Must.Field("otpCode1", params.OtpCode1).Require("please input first otp code")
params.Must.Field("otpCode2", params.OtpCode2).Require("please input second otp code")
this.Success()
}

View File

@@ -0,0 +1,66 @@
package guide
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
httpdnsApps "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/apps"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
httpdnsPolicies "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/policies"
"github.com/iwind/TeaGo/maps"
)
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) Init() {
this.Nav("httpdns", "guide", "")
}
func (this *IndexAction) RunGet(params struct{}) {
httpdnsutils.AddLeftMenu(this.Parent())
apps := []maps.Map{
{
"id": int64(1),
"name": "主站移动业务",
"appId": "ab12xc34s2",
"clusterId": int64(1),
},
{
"id": int64(2),
"name": "视频网关业务",
"appId": "vd8992ksm1",
"clusterId": int64(2),
},
{
"id": int64(3),
"name": "海外灰度测试",
"appId": "ov7711hkq9",
"clusterId": int64(1),
},
}
for _, app := range apps {
clusterID := app.GetInt64("clusterId")
app["gatewayDomain"] = httpdnsPolicies.LoadClusterGatewayByID(clusterID)
settings := httpdnsApps.LoadAppSettingsByAppID(app.GetString("appId"))
if settings == nil {
app["signSecret"] = ""
app["signSecretMasked"] = ""
app["aesSecret"] = ""
app["aesSecretMasked"] = ""
app["signEnabled"] = false
continue
}
app["signSecret"] = settings.GetString("signSecretPlain")
app["signSecretMasked"] = settings.GetString("signSecretMasked")
app["aesSecret"] = settings.GetString("aesSecretPlain")
app["aesSecretMasked"] = settings.GetString("aesSecretMasked")
app["signEnabled"] = settings.GetBool("signEnabled")
}
this.Data["apps"] = apps
this.Show()
}

View File

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

View File

@@ -0,0 +1,49 @@
package httpdnsutils
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
func AddLeftMenu(action *actionutils.ParentAction) {
tab := action.Data.GetString("mainTab")
action.Data["teaMenu"] = "httpdns"
action.Data["teaSubMenu"] = tab
action.Data["leftMenuItems"] = []maps.Map{
{
"name": "集群管理",
"url": "/httpdns/clusters",
"isActive": tab == "cluster",
},
{
"name": "全局配置",
"url": "/httpdns/policies",
"isActive": tab == "policy",
},
{
"name": "应用管理",
"url": "/httpdns/apps",
"isActive": tab == "app",
},
{
"name": "SDK接入引导",
"url": "/httpdns/guide",
"isActive": tab == "guide",
},
{
"name": "解析日志",
"url": "/httpdns/resolveLogs",
"isActive": tab == "resolveLogs",
},
{
"name": "运行日志",
"url": "/httpdns/runtimeLogs",
"isActive": tab == "runtimeLogs",
},
{
"name": "解析测试",
"url": "/httpdns/sandbox",
"isActive": tab == "sandbox",
},
}
}

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
package policies
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
"github.com/iwind/TeaGo/actions"
"github.com/iwind/TeaGo/maps"
)
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) Init() {
this.Nav("httpdns", "policy", "")
}
func (this *IndexAction) RunGet(params struct{}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["policies"] = loadGlobalPolicies()
this.Data["availableClusters"] = loadAvailableDeployClusters()
this.Show()
}
func (this *IndexAction) RunPost(params struct {
DefaultClusterId int64
EnableUserDomainVerify bool
DefaultTTL int
DefaultFallbackMs int
Must *actions.Must
CSRF *actionutils.CSRF
}) {
if params.DefaultClusterId <= 0 || !isValidClusterID(params.DefaultClusterId) {
this.Fail("please select a valid default cluster")
return
}
params.Must.Field("defaultTTL", params.DefaultTTL).Gt(0, "default ttl should be > 0")
params.Must.Field("defaultFallbackMs", params.DefaultFallbackMs).Gt(0, "default fallback should be > 0")
if params.DefaultTTL > 86400 {
this.Fail("default TTL should be <= 86400")
return
}
if params.DefaultFallbackMs > 10000 {
this.Fail("default fallback should be <= 10000 ms")
return
}
saveGlobalPolicies(maps.Map{
"defaultClusterId": params.DefaultClusterId,
"enableUserDomainVerify": params.EnableUserDomainVerify,
"defaultTTL": params.DefaultTTL,
"defaultFallbackMs": params.DefaultFallbackMs,
})
this.Success()
}
func isValidClusterID(clusterID int64) bool {
for _, cluster := range loadAvailableDeployClusters() {
if cluster.GetInt64("id") == clusterID {
return true
}
}
return false
}

View File

@@ -0,0 +1,19 @@
package policies
import (
"github.com/TeaOSLab/EdgeAdmin/internal/configloaders"
"github.com/TeaOSLab/EdgeAdmin/internal/web/helpers"
"github.com/iwind/TeaGo"
)
func init() {
TeaGo.BeforeStart(func(server *TeaGo.Server) {
server.
Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeHttpDNS)).
Data("teaMenu", "httpdns").
Data("teaSubMenu", "policy").
Prefix("/httpdns/policies").
GetPost("", new(IndexAction)).
EndAll()
})
}

View File

@@ -0,0 +1,172 @@
package policies
import (
"strings"
"sync"
"github.com/iwind/TeaGo/maps"
)
var globalPoliciesStore = struct {
sync.RWMutex
data maps.Map
publicSniPools []maps.Map
publicSniCertificates []maps.Map
}{
data: maps.Map{
"defaultClusterId": int64(1),
"enableUserDomainVerify": true,
"defaultTTL": 30,
"defaultFallbackMs": 300,
},
publicSniPools: []maps.Map{
{
"domain": "public-sni-a.waf.example.com",
"certId": "cert_public_sni_a",
},
{
"domain": "public-sni-b.waf.example.com",
"certId": "cert_public_sni_b",
},
},
publicSniCertificates: []maps.Map{
{
"id": "cert_public_sni_a",
"name": "public-sni-a.waf.example.com",
"issuer": "Mock CA",
"expiresAt": "2026-12-31 23:59:59",
},
{
"id": "cert_public_sni_b",
"name": "public-sni-b.waf.example.com",
"issuer": "Mock CA",
"expiresAt": "2027-03-31 23:59:59",
},
},
}
func loadGlobalPolicies() maps.Map {
globalPoliciesStore.RLock()
defer globalPoliciesStore.RUnlock()
return maps.Map{
"defaultClusterId": globalPoliciesStore.data.GetInt64("defaultClusterId"),
"enableUserDomainVerify": globalPoliciesStore.data.GetBool("enableUserDomainVerify"),
"defaultTTL": globalPoliciesStore.data.GetInt("defaultTTL"),
"defaultFallbackMs": globalPoliciesStore.data.GetInt("defaultFallbackMs"),
}
}
func saveGlobalPolicies(policies maps.Map) {
globalPoliciesStore.Lock()
globalPoliciesStore.data = maps.Map{
"defaultClusterId": policies.GetInt64("defaultClusterId"),
"enableUserDomainVerify": policies.GetBool("enableUserDomainVerify"),
"defaultTTL": policies.GetInt("defaultTTL"),
"defaultFallbackMs": policies.GetInt("defaultFallbackMs"),
}
globalPoliciesStore.Unlock()
}
func loadPublicSNICertificates() []maps.Map {
globalPoliciesStore.RLock()
defer globalPoliciesStore.RUnlock()
return cloneMapSlice(globalPoliciesStore.publicSniCertificates)
}
// LoadPublicSNICertificates returns global public SNI certificates for other httpdns modules.
func LoadPublicSNICertificates() []maps.Map {
return loadPublicSNICertificates()
}
func loadAvailableDeployClusters() []maps.Map {
return []maps.Map{
{
"id": int64(1),
"name": "gateway-cn-hz",
"region": "cn-hangzhou",
"gatewayDomain": "gw-hz.httpdns.example.com",
},
{
"id": int64(2),
"name": "gateway-cn-bj",
"region": "cn-beijing",
"gatewayDomain": "gw-bj.httpdns.example.com",
},
}
}
// LoadAvailableDeployClusters returns selectable clusters for HTTPDNS user defaults.
func LoadAvailableDeployClusters() []maps.Map {
return loadAvailableDeployClusters()
}
// LoadDefaultClusterID returns current default deploy cluster id for HTTPDNS users.
func LoadDefaultClusterID() int64 {
globalPoliciesStore.RLock()
defer globalPoliciesStore.RUnlock()
return globalPoliciesStore.data.GetInt64("defaultClusterId")
}
// LoadClusterGatewayByID returns gateway domain for the selected cluster.
func LoadClusterGatewayByID(clusterID int64) string {
clusters := loadAvailableDeployClusters()
for _, cluster := range clusters {
if cluster.GetInt64("id") == clusterID {
gatewayDomain := strings.TrimSpace(cluster.GetString("gatewayDomain"))
if len(gatewayDomain) > 0 {
return gatewayDomain
}
}
}
if len(clusters) > 0 {
fallback := strings.TrimSpace(clusters[0].GetString("gatewayDomain"))
if len(fallback) > 0 {
return fallback
}
}
return "gw.httpdns.example.com"
}
// LoadPublicSNIPools returns global public SNI domain pool for other httpdns modules.
func LoadPublicSNIPools() []maps.Map {
globalPoliciesStore.RLock()
defer globalPoliciesStore.RUnlock()
return cloneMapSlice(globalPoliciesStore.publicSniPools)
}
// HasPublicSNIDomain checks if a public SNI domain exists in global pool.
func HasPublicSNIDomain(domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
if len(domain) == 0 {
return false
}
globalPoliciesStore.RLock()
defer globalPoliciesStore.RUnlock()
for _, pool := range globalPoliciesStore.publicSniPools {
if strings.ToLower(strings.TrimSpace(pool.GetString("domain"))) == domain {
return true
}
}
return false
}
func cloneMapSlice(src []maps.Map) []maps.Map {
result := make([]maps.Map, 0, len(src))
for _, item := range src {
cloned := maps.Map{}
for k, v := range item {
cloned[k] = v
}
result = append(result, cloned)
}
return result
}

View File

@@ -0,0 +1,151 @@
package resolveLogs
import (
"strings"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) Init() {
this.Nav("httpdns", "resolveLogs", "")
}
func (this *IndexAction) RunGet(params struct {
ClusterId int64
NodeId int64
AppId string
Domain string
Status string
Keyword string
}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["clusterId"] = params.ClusterId
this.Data["nodeId"] = params.NodeId
this.Data["appId"] = params.AppId
this.Data["domain"] = params.Domain
this.Data["status"] = params.Status
this.Data["keyword"] = params.Keyword
clusters := []map[string]interface{}{
{"id": int64(1), "name": "gateway-cn-hz"},
{"id": int64(2), "name": "gateway-cn-bj"},
}
nodes := []map[string]interface{}{
{"id": int64(101), "clusterId": int64(1), "name": "hz-node-01"},
{"id": int64(102), "clusterId": int64(1), "name": "hz-node-02"},
{"id": int64(201), "clusterId": int64(2), "name": "bj-node-01"},
}
this.Data["clusters"] = clusters
this.Data["nodes"] = nodes
allLogs := []map[string]interface{}{
{
"time": "2026-02-23 10:21:51",
"clusterId": int64(1),
"clusterName": "gateway-cn-hz",
"nodeId": int64(101),
"nodeName": "hz-node-01",
"appName": "\u4e3b\u7ad9\u79fb\u52a8\u4e1a\u52a1",
"appId": "ab12xc34s2",
"domain": "api.business.com",
"query": "A",
"clientIp": "203.0.113.25",
"os": "Android",
"sdkVersion": "2.4.1",
"ips": "198.51.100.10,198.51.100.11",
"status": "success",
"errorCode": "none",
"costMs": 37,
"pinningMode": "report",
"pinningResult": "warn",
"sanMode": "strict",
"sanResult": "pass",
},
{
"time": "2026-02-23 10:18:02",
"clusterId": int64(2),
"clusterName": "gateway-cn-bj",
"nodeId": int64(201),
"nodeName": "bj-node-01",
"appName": "\u652f\u4ed8\u7f51\u5173\u4e1a\u52a1",
"appId": "vd8992ksm1",
"domain": "payment.business.com",
"query": "A",
"clientIp": "198.51.100.67",
"os": "iOS",
"sdkVersion": "2.3.9",
"ips": "198.51.100.22",
"status": "failed",
"errorCode": "40301",
"costMs": 52,
"pinningMode": "enforce",
"pinningResult": "fail",
"sanMode": "strict",
"sanResult": "fail",
},
{
"time": "2026-02-23 10:12:44",
"clusterId": int64(1),
"clusterName": "gateway-cn-hz",
"nodeId": int64(102),
"nodeName": "hz-node-02",
"appName": "\u4e3b\u7ad9\u79fb\u52a8\u4e1a\u52a1",
"appId": "ab12xc34s2",
"domain": "www.aliyun.com",
"query": "A",
"clientIp": "106.11.63.180",
"os": "Unknown",
"sdkVersion": "none",
"ips": "3.33.120.190,15.197.120.33",
"status": "success",
"errorCode": "none",
"costMs": 41,
"pinningMode": "off",
"pinningResult": "na",
"sanMode": "report",
"sanResult": "warn",
},
}
status := strings.TrimSpace(strings.ToLower(params.Status))
appID := strings.TrimSpace(strings.ToLower(params.AppId))
domain := strings.TrimSpace(strings.ToLower(params.Domain))
keyword := strings.TrimSpace(strings.ToLower(params.Keyword))
filtered := make([]map[string]interface{}, 0)
for _, log := range allLogs {
if params.ClusterId > 0 && log["clusterId"].(int64) != params.ClusterId {
continue
}
if params.NodeId > 0 && log["nodeId"].(int64) != params.NodeId {
continue
}
if len(status) > 0 && log["status"].(string) != status {
continue
}
if len(appID) > 0 && !strings.Contains(strings.ToLower(log["appId"].(string)), appID) {
continue
}
if len(domain) > 0 && !strings.Contains(strings.ToLower(log["domain"].(string)), domain) {
continue
}
if len(keyword) > 0 {
if !strings.Contains(strings.ToLower(log["appName"].(string)), keyword) &&
!strings.Contains(strings.ToLower(log["domain"].(string)), keyword) &&
!strings.Contains(strings.ToLower(log["clientIp"].(string)), keyword) &&
!strings.Contains(strings.ToLower(log["ips"].(string)), keyword) {
continue
}
}
filtered = append(filtered, log)
}
this.Data["resolveLogs"] = filtered
this.Show()
}

View File

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

View File

@@ -0,0 +1,137 @@
package runtimeLogs
import (
"strings"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) Init() {
this.Nav("httpdns", "runtimeLogs", "")
}
func (this *IndexAction) RunGet(params struct {
ClusterId int64
NodeId int64
DayFrom string
DayTo string
Level string
Keyword string
}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["clusterId"] = params.ClusterId
this.Data["nodeId"] = params.NodeId
this.Data["dayFrom"] = params.DayFrom
this.Data["dayTo"] = params.DayTo
this.Data["level"] = params.Level
this.Data["keyword"] = params.Keyword
clusters := []map[string]interface{}{
{"id": int64(1), "name": "gateway-cn-hz"},
{"id": int64(2), "name": "gateway-cn-bj"},
}
nodes := []map[string]interface{}{
{"id": int64(101), "clusterId": int64(1), "name": "hz-node-01"},
{"id": int64(102), "clusterId": int64(1), "name": "hz-node-02"},
{"id": int64(201), "clusterId": int64(2), "name": "bj-node-01"},
}
this.Data["clusters"] = clusters
this.Data["nodes"] = nodes
allLogs := []map[string]interface{}{
{
"createdTime": "2026-02-23 10:30:12",
"clusterId": int64(1),
"clusterName": "gateway-cn-hz",
"nodeId": int64(101),
"nodeName": "hz-node-01",
"level": "info",
"tag": "config",
"module": "resolver",
"description": "\u914d\u7f6e\u70ed\u52a0\u8f7d\u5b8c\u6210\uff0c\u5df2\u542f\u7528\u6700\u65b0\u7b56\u7565\u5feb\u7167\u3002",
"count": int64(1),
"requestId": "rid-20260223-001",
},
{
"createdTime": "2026-02-23 10:27:38",
"clusterId": int64(2),
"clusterName": "gateway-cn-bj",
"nodeId": int64(201),
"nodeName": "bj-node-01",
"level": "warning",
"tag": "sni",
"module": "policy-engine",
"description": "\u68c0\u6d4b\u5230 level3 \u6761\u4ef6\u4e0d\u6ee1\u8db3\uff0c\u81ea\u52a8\u964d\u7ea7\u5230 level2\u3002",
"count": int64(3),
"requestId": "rid-20260223-002",
},
{
"createdTime": "2026-02-23 10:22:49",
"clusterId": int64(1),
"clusterName": "gateway-cn-hz",
"nodeId": int64(102),
"nodeName": "hz-node-02",
"level": "success",
"tag": "cache-refresh",
"module": "cache",
"description": "\u57df\u540d\u7f13\u5b58\u5237\u65b0\u4efb\u52a1\u5b8c\u6210\uff0c\u6210\u529f 2/2\u3002",
"count": int64(1),
"requestId": "rid-20260223-003",
},
{
"createdTime": "2026-02-23 10:18:11",
"clusterId": int64(1),
"clusterName": "gateway-cn-hz",
"nodeId": int64(101),
"nodeName": "hz-node-01",
"level": "error",
"tag": "upstream",
"module": "resolver",
"description": "\u4e0a\u6e38\u6743\u5a01 DNS \u8bf7\u6c42\u8d85\u65f6\uff0c\u89e6\u53d1\u91cd\u8bd5\u540e\u6062\u590d\u3002",
"count": int64(2),
"requestId": "rid-20260223-004",
},
}
keyword := strings.TrimSpace(strings.ToLower(params.Keyword))
filtered := make([]map[string]interface{}, 0)
for _, log := range allLogs {
if params.ClusterId > 0 && log["clusterId"].(int64) != params.ClusterId {
continue
}
if params.NodeId > 0 && log["nodeId"].(int64) != params.NodeId {
continue
}
if len(params.Level) > 0 && log["level"].(string) != params.Level {
continue
}
if len(params.DayFrom) > 0 {
if log["createdTime"].(string)[:10] < params.DayFrom {
continue
}
}
if len(params.DayTo) > 0 {
if log["createdTime"].(string)[:10] > params.DayTo {
continue
}
}
if len(keyword) > 0 {
if !strings.Contains(strings.ToLower(log["tag"].(string)), keyword) &&
!strings.Contains(strings.ToLower(log["module"].(string)), keyword) &&
!strings.Contains(strings.ToLower(log["description"].(string)), keyword) &&
!strings.Contains(strings.ToLower(log["nodeName"].(string)), keyword) {
continue
}
}
filtered = append(filtered, log)
}
this.Data["runtimeLogs"] = filtered
this.Show()
}

View File

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

View File

@@ -0,0 +1,36 @@
package sandbox
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
)
type IndexAction struct {
actionutils.ParentAction
}
func (this *IndexAction) Init() {
this.Nav("httpdns", "sandbox", "")
}
func (this *IndexAction) RunGet(params struct{}) {
httpdnsutils.AddLeftMenu(this.Parent())
this.Data["apps"] = []map[string]interface{}{
{
"id": int64(1),
"name": "主站移动业务",
"appId": "ab12xc34s2",
},
{
"id": int64(2),
"name": "视频网关业务",
"appId": "vd8992ksm1",
},
{
"id": int64(3),
"name": "海外灰度测试",
"appId": "ov7711hkq9",
},
}
this.Show()
}

View File

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

View File

@@ -0,0 +1,163 @@
package sandbox
import (
"encoding/json"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
type TestAction struct {
actionutils.ParentAction
}
func (this *TestAction) RunPost(params struct {
AppId string
Domain string
ClientIp string
Qtype string
SDNSParamsJSON string
}) {
if len(params.ClientIp) == 0 {
params.ClientIp = "203.0.113.100"
}
params.SDNSParamsJSON = strings.TrimSpace(params.SDNSParamsJSON)
sdnsParams, err := parseSDNSParamsJSON(params.SDNSParamsJSON)
if err != nil {
this.Fail(err.Error())
return
}
clientSubnet := this.maskSubnet(params.ClientIp, 24, 56)
if len(clientSubnet) == 0 {
clientSubnet = "203.0.113.0/24"
}
sniPolicy := "empty"
publicSNI := ""
ecsMode := "off"
ecsIPv4Prefix := 24
ecsIPv6Prefix := 56
pinningMode := "off"
sanMode := "off"
query := url.Values{}
query.Set("appId", params.AppId)
query.Set("dn", params.Domain)
query.Set("cip", params.ClientIp)
query.Set("qtype", params.Qtype)
for _, item := range sdnsParams {
query.Add("sdns_"+item.GetString("name"), item.GetString("value"))
}
requestURL := "https://api.httpdns.example.com/resolve?" + query.Encode()
this.Data["result"] = maps.Map{
"code": 0,
"message": "ok (mock)",
"requestId": "mock-rid-20260221-001",
"data": maps.Map{
"request_url": requestURL,
"client_ip": params.ClientIp,
"client_region": "中国, 上海, 上海",
"line_name": "默认线路",
"sdns_params": sdnsParams,
"ips": []string{"203.0.113.10", "203.0.113.11"},
"records": []maps.Map{
{
"domain": params.Domain,
"type": params.Qtype,
"ip": "203.0.113.10",
"ttl": 30,
"region": "中国-上海-上海",
"line": "默认线路",
},
{
"domain": params.Domain,
"type": params.Qtype,
"ip": "203.0.113.11",
"ttl": 30,
"region": "中国-上海-上海",
"line": "默认线路",
},
},
"ttl": 30,
"sni_policy": sniPolicy,
"public_sni": publicSNI,
"client_subnet": clientSubnet,
"ecs_mode": ecsMode,
"ecs_ipv4_prefix": ecsIPv4Prefix,
"ecs_ipv6_prefix": ecsIPv6Prefix,
"pinning_mode": pinningMode,
"san_mode": sanMode,
"fingerprint_algo": "sha256",
"cert_fingerprints": []string{"8f41ab8d32fca7f9a2b0fdd09a0e2ccf31e5579cd3a0b65f1a9f2f2ec8f38d4a"},
"verify_headers": maps.Map{
"X-Edge-Resolve-AppId": params.AppId,
"X-Edge-Resolve-Expire": "1768953600",
"X-Edge-Resolve-Nonce": "mock-nonce-123456",
"X-Edge-Resolve-Target": params.Domain,
"X-Edge-Resolve-Sign": "mock-signature-base64url",
},
},
}
this.Success()
}
func parseSDNSParamsJSON(raw string) ([]maps.Map, error) {
if len(raw) == 0 {
return []maps.Map{}, nil
}
list := []maps.Map{}
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return nil, err
}
if len(list) > 10 {
return nil, fmt.Errorf("sdns params should be <= 10")
}
result := make([]maps.Map, 0, len(list))
for _, item := range list {
name := strings.TrimSpace(item.GetString("name"))
value := strings.TrimSpace(item.GetString("value"))
if len(name) == 0 && len(value) == 0 {
continue
}
if len(name) == 0 || len(name) > 64 {
return nil, fmt.Errorf("sdns param name should be in 1-64")
}
if len(value) == 0 || len(value) > 64 {
return nil, fmt.Errorf("sdns param value should be in 1-64")
}
result = append(result, maps.Map{
"name": name,
"value": value,
})
}
if len(result) > 10 {
return nil, fmt.Errorf("sdns params should be <= 10")
}
return result, nil
}
func (this *TestAction) maskSubnet(ip string, ipv4Prefix int, ipv6Prefix int) string {
parsed := net.ParseIP(ip)
if parsed == nil {
return ""
}
ipv4 := parsed.To4()
if ipv4 != nil {
mask := net.CIDRMask(ipv4Prefix, 32)
return ipv4.Mask(mask).String() + "/" + strconv.Itoa(ipv4Prefix)
}
mask := net.CIDRMask(ipv6Prefix, 128)
return parsed.Mask(mask).String() + "/" + strconv.Itoa(ipv6Prefix)
}

View File

@@ -113,6 +113,50 @@ func FindAllMenuMaps(langCode string, nodeLogsType string, countUnreadNodeLogs i
}, },
}, },
}, },
{
"code": "httpdns",
"module": configloaders.AdminModuleCodeHttpDNS,
"name": "HTTPDNS",
"subtitle": "",
"icon": "shield alternate",
"subItems": []maps.Map{
{
"name": "集群管理",
"url": "/httpdns/clusters",
"code": "cluster",
},
{
"name": "全局配置",
"url": "/httpdns/policies",
"code": "policy",
},
{
"name": "应用管理",
"url": "/httpdns/apps",
"code": "app",
},
{
"name": "SDK接入引导",
"url": "/httpdns/guide",
"code": "guide",
},
{
"name": "解析日志",
"url": "/httpdns/resolveLogs",
"code": "resolveLogs",
},
{
"name": "运行日志",
"url": "/httpdns/runtimeLogs",
"code": "runtimeLogs",
},
{
"name": "解析测试",
"url": "/httpdns/sandbox",
"code": "sandbox",
},
},
},
{ {
"code": "dns", "code": "dns",
"module": configloaders.AdminModuleCodeDNS, "module": configloaders.AdminModuleCodeDNS,

View File

@@ -178,6 +178,50 @@ func FindAllMenuMaps(langCode string, nodeLogsType string, countUnreadNodeLogs i
}, },
}, },
}, },
{
"code": "httpdns",
"module": configloaders.AdminModuleCodeHttpDNS,
"name": "HTTPDNS",
"subtitle": "",
"icon": "shield alternate",
"subItems": []maps.Map{
{
"name": "集群管理",
"url": "/httpdns/clusters",
"code": "cluster",
},
{
"name": "全局配置",
"url": "/httpdns/policies",
"code": "policy",
},
{
"name": "应用管理",
"url": "/httpdns/apps",
"code": "app",
},
{
"name": "SDK接入引导",
"url": "/httpdns/guide",
"code": "guide",
},
{
"name": "解析日志",
"url": "/httpdns/resolveLogs",
"code": "resolveLogs",
},
{
"name": "运行日志",
"url": "/httpdns/runtimeLogs",
"code": "runtimeLogs",
},
{
"name": "解析测试",
"url": "/httpdns/sandbox",
"code": "sandbox",
},
},
},
{ {
"code": "dns", "code": "dns",
"module": configloaders.AdminModuleCodeDNS, "module": configloaders.AdminModuleCodeDNS,

View File

@@ -136,4 +136,15 @@ import (
// 平台用户 // 平台用户
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/users" _ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/users"
// HTTPDNS体系
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns"
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/apps"
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/clusters"
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/ech"
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/guide"
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/policies"
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/resolveLogs"
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/runtimeLogs"
_ "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/sandbox"
) )

View File

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

View File

@@ -0,0 +1,116 @@
{$layout}
{$template "menu"}
<style>
.httpdns-settings-grid {
margin-top: 0 !important;
}
.httpdns-settings-grid .left-nav-column {
max-width: 220px;
}
.httpdns-settings-grid .right-form-column {
padding-left: .3em !important;
}
.httpdns-side-menu .item {
padding-top: .8em !important;
padding-bottom: .8em !important;
}
.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;
}
</style>
<div>
<div class="ui menu text blue">
<div class="item"><strong>{{app.name}}</strong> (<code>{{settings.appId}}</code>)</div>
</div>
<div class="ui divider"></div>
<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" />
<div class="ui stackable grid httpdns-settings-grid">
<div class="three wide computer four wide tablet sixteen wide mobile column left-nav-column">
<div class="ui fluid vertical pointing menu httpdns-side-menu">
<a href="" class="item" :class="{active: activeSection == 'basic'}" @click.prevent="activeSection='basic'">基础配置</a>
<a href="" class="item" :class="{active: activeSection == 'auth'}" @click.prevent="activeSection='auth'">认证与密钥</a>
</div>
</div>
<div class="thirteen wide computer twelve wide tablet sixteen wide mobile column right-form-column">
<table class="ui table selectable definition" v-show="activeSection == 'basic'">
<tr>
<td class="title">App ID</td>
<td><code>{{settings.appId}}</code></td>
</tr>
<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" v-show="activeSection == 'auth'">
<tr>
<td class="title">请求验签</td>
<td>
<span class="ui label tiny green" v-if="settings.signEnabled">已开启</span>
<span class="ui label tiny basic" v-else>已关闭</span>
<a href="" class="ui mini button" :class="settings.signEnabled ? 'basic' : 'primary'" 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>
<code>{{signSecretVisible ? settings.signSecretPlain : settings.signSecretMasked}}</code>
<a href="" class="httpdns-mini-action" @click.prevent="signSecretVisible = !signSecretVisible" :title="signSecretVisible ? '隐藏明文' : '查看明文'"><i class="icon" :class="signSecretVisible ? 'eye slash' : 'eye'"></i></a>
<a href="" class="httpdns-mini-action" title="复制加签 Secret" @click.prevent="copySecret(settings.signSecretPlain, '加签 Secret')"><i class="copy outline icon"></i></a>
<a href="" class="httpdns-mini-action" @click.prevent="resetSignSecret">[重置]</a>
<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>
<tr>
<td class="title">AES 数据加密 Secret</td>
<td>
<code>{{aesSecretVisible ? settings.aesSecretPlain : settings.aesSecretMasked}}</code>
<a href="" class="httpdns-mini-action" @click.prevent="aesSecretVisible = !aesSecretVisible" :title="aesSecretVisible ? '隐藏明文' : '查看明文'"><i class="icon" :class="aesSecretVisible ? 'eye slash' : 'eye'"></i></a>
<a href="" class="httpdns-mini-action" title="复制 AES 数据加密 Secret" @click.prevent="copySecret(settings.aesSecretPlain, 'AES Secret')"><i class="copy outline icon"></i></a>
<a href="" class="httpdns-mini-action" @click.prevent="resetAESSecret">[重置]</a>
<p class="comment httpdns-note">最近更新:{{settings.aesSecretUpdatedAt}}</p>
<p class="comment httpdns-note">用于解析接口数据加密的密钥。</p>
</td>
</tr>
</table>
<submit-btn></submit-btn>
</div>
</div>
</form>
</div>

View File

@@ -0,0 +1,111 @@
Tea.context(function () {
this.activeSection = "basic";
this.success = NotifyReloadSuccess("保存成功");
this.signSecretVisible = false;
this.aesSecretVisible = 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();
});
});
});
};
this.resetAESSecret = function () {
let that = this;
teaweb.confirm("确定要重置 AES Secret 吗?", function () {
that.$post("/httpdns/apps/app/settings/resetAESSecret")
.params({
appId: that.app.id
})
.success(function () {
teaweb.success("AES Secret 已重置", function () {
teaweb.reload();
});
});
});
};
});

View File

@@ -0,0 +1,37 @@
{$layout "layout_popup"}
<h3>添加应用</h3>
<form method="post" class="ui form" data-tea-action="$">
<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>
<tr>
<td>所属集群 *</td>
<td>
<select class="ui dropdown" name="clusterId" v-model="defaultClusterId">
<option value="">[请选择集群]</option>
<option v-for="cluster in clusters" :value="cluster.id">{{cluster.name}}</option>
</select>
<p class="comment">应用的解析请求将由所选集群下的网关节点处理。默认值来自“HTTPDNS 用户设置”。</p>
</td>
</tr>
<tr>
<td>所属用户</td>
<td>
<select class="ui dropdown" name="userId">
<option value="0">[平台自用 / 不指定]</option>
<option v-for="user in users" :value="user.id">{{user.name}} ({{user.username}})</option>
</select>
<p class="comment">可分配给指定租户用户;留空表示平台管理员自用。</p>
</td>
</tr>
</table>
<submit-btn></submit-btn>
</form>

View File

@@ -0,0 +1,66 @@
{$layout}
{$template "menu"}
<style>
.httpdns-col-ttl {
width: 72px;
white-space: nowrap;
}
.httpdns-col-actions {
width: 130px;
white-space: nowrap;
}
</style>
<div>
<div class="ui menu text blue">
<div class="item"><strong>{{app.name}}</strong> (<code>{{app.appId}}</code>)</div>
</div>
<div class="ui divider"></div>
<div class="ui small message">
当前域名:<strong>{{domain.name}}</strong>
</div>
<a href="" @click.prevent="createRecord" class="ui button primary small" :class="{disabled: !domain || !domain.id}">
<i class="icon plus"></i> 新增自定义解析规则
</a>
<a :href="'/httpdns/apps/domains?appId=' + app.id" class="ui button small">返回域名管理</a>
<table class="ui table selectable celled" v-if="records.length > 0" style="margin-top: 1em;">
<thead>
<tr>
<th>线路</th>
<th>规则名称</th>
<th>SDNS 参数</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.paramsText}}</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>
<p class="ui warning message" v-if="!domain || !domain.id">当前应用暂无可用域名,请先到域名管理中添加域名。</p>
<p class="ui message" v-else-if="records.length == 0">当前域名还没有自定义解析规则。</p>
</div>

View File

@@ -0,0 +1,50 @@
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: "56em",
height: "40em",
title: "新增自定义解析规则"
});
};
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: "56em",
height: "40em",
title: "编辑自定义解析规则"
});
};
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,158 @@
{$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="$">
<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="sdnsParamsJSON" :value="JSON.stringify(sdnsParams)" />
<input type="hidden" name="recordItemsJSON" :value="JSON.stringify(recordItems)" />
<table class="ui table definition selectable">
<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">SDNS 参数配置</td>
<td>
<div class="httpdns-row" v-for="(param, index) in sdnsParams">
<div class="field flex">
<input type="text" maxlength="64" placeholder="参数名称" v-model="param.name" />
</div>
<div class="field flex">
<input type="text" maxlength="64" placeholder="参数值" v-model="param.value" />
</div>
<a href="" @click.prevent="removeSDNSParam(index)" title="删除"><i class="icon trash alternate outline"></i></a>
</div>
<div class="httpdns-inline-actions">
<a href="" @click.prevent="addSDNSParam" :class="{disabled: sdnsParams.length >= 10}">
<i class="icon plus circle"></i>添加参数
</a>
<span class="count">{{sdnsParams.length}}/10</span>
</div>
</td>
</tr>
<tr>
<td class="title">解析记录值 *</td>
<td>
<div style="float:right;">
<div class="ui checkbox">
<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:8em;" placeholder="权重(1-100)" v-model="item.weight" />
</div>
<a href="" @click.prevent="removeRecordItem(index)" title="删除"><i class="icon trash alternate outline"></i></a>
</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>
<p class="comment" v-if="record.weightEnabled">开启后每条记录可配置权重,范围 1-100。</p>
</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>
</table>
<submit-btn></submit-btn>
</form>

View File

@@ -0,0 +1,177 @@
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.sdnsParams = vm.parseJSONList(vm.record.sdnsParamsJson);
if (vm.sdnsParams.length == 0) {
vm.sdnsParams.push({name: "", value: ""});
}
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.addSDNSParam = function () {
if (vm.sdnsParams.length >= 10) {
return;
}
vm.sdnsParams.push({name: "", value: ""});
};
vm.removeSDNSParam = function (index) {
if (index < 0 || index >= vm.sdnsParams.length) {
return;
}
vm.sdnsParams.splice(index, 1);
if (vm.sdnsParams.length == 0) {
vm.sdnsParams.push({name: "", value: ""});
}
};
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,35 @@
{$layout}
{$template "menu"}
<div>
<div class="ui menu text blue">
<div class="item"><strong>{{app.name}}</strong> (<code>{{app.appId}}</code>)</div>
</div>
<div class="ui divider"></div>
<a href="" @click.prevent="bindDomain" class="ui button primary small"><i class="icon plus"></i>添加域名</a>
<table class="ui table selectable celled" v-if="domains.length > 0" style="margin-top: 1em;">
<thead>
<tr>
<th>域名列表</th>
<th class="two wide">规则策略</th>
<th class="two op">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="domain in domains">
<td><strong>{{domain.name}}</strong></td>
<td>
<a :href="'/httpdns/apps/customRecords?appId=' + app.id + '&domainId=' + domain.id">{{domain.customRecordCount}}</a>
</td>
<td>
<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>
<p class="ui message" v-if="domains.length == 0">该应用尚未绑定域名。</p>
</div>

View File

@@ -0,0 +1,24 @@
Tea.context(function () {
if (typeof this.domains == "undefined") {
this.domains = [];
}
this.bindDomain = function () {
teaweb.popup("/httpdns/apps/domains/createPopup?appId=" + this.app.id, {
height: "24em",
width: "46em",
title: "添加域名"
});
};
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,17 @@
{$layout "layout_popup"}
<h3>添加域名</h3>
<form method="post" class="ui form" data-tea-action="$">
<csrf-token></csrf-token>
<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,53 @@
{$layout}
{$template "menu"}
<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" v-if="apps.length > 0">
<thead>
<tr>
<th>应用名称</th>
<th>AppID</th>
<th>绑定域名数</th>
<th class="one wide center">状态</th>
<th class="tz op">操作</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><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>
<a :href="'/httpdns/apps/app?appId=' + app.id">域名管理</a> &nbsp;|&nbsp;
<a :href="'/httpdns/apps/app/settings?appId=' + app.id">应用设置</a>
</td>
</tr>
</tbody>
</table>
<div class="page" v-html="page"></div>
</div>

View File

@@ -0,0 +1,13 @@
Tea.context(function () {
if (typeof this.apps == "undefined") {
this.apps = [];
}
this.createApp = function () {
teaweb.popup("/httpdns/apps/createPopup", {
height: "26em",
width: "48em",
title: "添加应用"
});
};
});

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,8 @@
<second-menu>
<menu-item :href="'/httpdns/clusters/cluster?clusterId=' + cluster.id">{{cluster.name}}</menu-item>
<span class="item disabled" style="padding-left: 0; padding-right: 0">&raquo;</span>
<menu-item :href="'/httpdns/clusters/cluster?clusterId=' + cluster.id" code="index">节点列表</menu-item>
<menu-item :href="'/httpdns/clusters/createNode?clusterId=' + cluster.id" code="createNode">创建节点</menu-item>
<menu-item :href="'/httpdns/clusters/cluster/settings?clusterId=' + cluster.id" code="settings">集群设置</menu-item>
<menu-item :href="'/httpdns/clusters/delete?clusterId=' + cluster.id" code="delete">删除集群</menu-item>
</second-menu>

View File

@@ -0,0 +1,6 @@
<!-- 左侧菜单由 Go Backend 自动生成注入,此处加首行子菜单使其符合标准平台样式 -->
<first-menu>
<menu-item href="/httpdns/clusters" code="index">集群列表</menu-item>
<span class="item disabled">|</span>
<menu-item href="/httpdns/clusters/create" code="create">[创建新集群]</menu-item>
</first-menu>

View File

@@ -0,0 +1,30 @@
{$layout}
{$template "menu"}
{$template "/left_menu_with_menu"}
<div class="right-box with-menu">
<h3 class="ui header">HTTPS 接口证书</h3>
<p class="comment">用于 HTTPDNS 接口证书管理与展示。</p>
<table class="ui table selectable celled">
<thead>
<tr>
<th style="width:45%;">证书名称</th>
<th style="width:25%;">颁发机构</th>
<th style="width:20%;">到期时间</th>
<th style="width:10%;">证书ID</th>
</tr>
</thead>
<tbody>
<tr v-for="cert in certs">
<td>{{cert.name}}</td>
<td>{{cert.issuer}}</td>
<td>{{cert.expiresAt}}</td>
<td><code>{{cert.id}}</code></td>
</tr>
<tr v-if="!certs || certs.length == 0">
<td colspan="4" class="grey">暂无证书。</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,91 @@
{$layout}
{$template "cluster_menu"}
<div class="ui margin"></div>
<div class="right-menu">
<a :href="'/httpdns/clusters/createNode?clusterId=' + clusterId" class="item">[创建节点]</a>
</div>
<form class="ui form" action="/httpdns/clusters/cluster">
<input type="hidden" name="clusterId" :value="clusterId" />
<div class="ui fields inline">
<div class="ui field">
<select class="ui dropdown" name="installedState" v-model="installState">
<option value="0">[安装状态]</option>
<option value="1">已安装</option>
<option value="2">未安装</option>
</select>
</div>
<div class="ui field">
<select class="ui dropdown" name="activeState" v-model="activeState">
<option value="0">[在线状态]</option>
<option value="1">在线</option>
<option value="2">离线</option>
</select>
</div>
<div class="ui field">
<input type="text" name="keyword" placeholder="节点名称、IP..." :value="keyword" />
</div>
<div class="ui field">
<button class="ui button" type="submit">搜索</button>
&nbsp;
<a :href="'/httpdns/clusters/cluster?clusterId=' + clusterId"
v-if="installState > 0 || activeState > 0 || keyword.length > 0">[清除条件]</a>
</div>
</div>
</form>
<div class="ui margin" v-if="!hasNodes"></div>
<p class="comment" v-if="!hasNodes">当前集群下暂时还没有节点。</p>
<table class="ui table selectable celled" v-if="hasNodes">
<thead>
<tr>
<th>节点名称</th>
<th>IP地址</th>
<th class="width-5 center">状态</th>
<th class="two op">操作</th>
</tr>
</thead>
<tbody v-for="node in nodes">
<tr>
<td>
<a
:href="'/httpdns/clusters/cluster/node?clusterId=' + clusterId + '&nodeId=' + node.id">{{node.name}}</a>
<a :href="'/httpdns/clusters/cluster/node/update?clusterId=' + clusterId + '&nodeId=' + node.id"
title="设置"><i class="icon setting grey"></i></a>
<div v-if="node.region != null">
<span class="grey small">{{node.region.name}}</span>
</div>
</td>
<td>
<span v-if="node.ipAddresses.length == 0" class="disabled">-</span>
<div v-for="addr in node.ipAddresses">
<div class="ui label tiny basic">{{addr.ip}}
<span class="small" v-if="addr.name.length > 0">{{addr.name}}</span>
<span class="small red" v-if="!addr.canAccess" title="不公开访问">[内]</span>
<span class="small red" v-if="!addr.isOn">[下线]</span>
<span class="small red" v-if="!addr.isUp" title="健康检查失败">[宕机]</span>
</div>
</div>
</td>
<td class="center">
<div v-if="!node.isUp">
<span class="red">宕机</span>
</div>
<div v-else-if="!node.isOn">
<span class="disabled">未启用</span>
</div>
<div v-else>
<span v-if="node.status.isActive" class="green">在线</span>
<span v-else class="disabled">离线</span>
</div>
</td>
<td>
<a :href="'/httpdns/clusters/cluster/node?clusterId=' + clusterId + '&nodeId=' + node.id">详情</a> &nbsp;
<a href="" @click.prevent="deleteNode(node.id)">删除</a>
</td>
</tr>
</tbody>
</table>
<div class="page" v-html="page"></div>

View File

@@ -0,0 +1,17 @@
Tea.context(function () {
this.deleteNode = function (nodeId) {
let that = this
teaweb.confirm("确定要删除此节点吗?", function () {
that.$post("/httpdns/clusters/deleteNode")
.params({
clusterId: that.clusterId,
nodeId: nodeId
})
.success(function () {
teaweb.success("删除成功", function () {
teaweb.reload()
})
})
})
}
})

View File

@@ -0,0 +1,10 @@
<second-menu>
<menu-item :href="'/httpdns/clusters/cluster?clusterId=' + currentCluster.id">{{currentCluster.name}}</menu-item>
<span class="item disabled" style="padding-left: 0; padding-right: 0">&raquo;</span>
<menu-item :href="'/httpdns/clusters/cluster?clusterId=' + clusterId">节点列表</menu-item>
<span class="item disabled">|</span>
<menu-item :href="'/httpdns/clusters/cluster/node?clusterId=' + clusterId + '&nodeId=' + nodeId" code="node">节点详情</menu-item>
<menu-item :href="'/httpdns/clusters/cluster/node/logs?clusterId=' + clusterId + '&nodeId=' + nodeId" code="log">运行日志</menu-item>
<menu-item :href="'/httpdns/clusters/cluster/node/update?clusterId=' + clusterId + '&nodeId=' + nodeId" code="update">修改设置</menu-item>
<menu-item :href="'/httpdns/clusters/cluster/node/install?clusterId=' + clusterId + '&nodeId=' + nodeId" code="install">安装节点</menu-item>
</second-menu>

View File

@@ -0,0 +1,4 @@
a.underline {
border-bottom: 1px #db2828 dashed;
}
/*# sourceMappingURL=index.css.map */

View File

@@ -0,0 +1 @@
{"version":3,"sources":["index.less"],"names":[],"mappings":"AAAA,CAAC;EACA,iCAAA","file":"index.css"}

View File

@@ -0,0 +1,191 @@
{$layout}
{$template "node_menu"}
<h3>节点详情</h3>
<table class="ui table definition selectable">
<tr>
<td class="title">节点名称</td>
<td>{{node.name}}</td>
</tr>
<tr>
<td>状态</td>
<td><label-on :v-is-on="node.isOn"></label-on></td>
</tr>
<tr>
<td>IP地址</td>
<td>
<div v-if="node.ipAddresses.length > 0">
<div>
<div v-for="(address, index) in node.ipAddresses" class="ui label tiny basic">
{{address.ip}}
<span class="small" v-if="address.name.length > 0">{{address.name}}<span v-if="!address.canAccess">,不公开访问</span></span>&nbsp;
<span class="small red" v-if="!address.isOn">[off]</span>
<span class="small red" v-if="!address.isUp">[down]</span>
<span class="small" v-if="address.name.length == 0 && !address.canAccess">(不公开访问)</span>
</div>
</div>
</div>
<div v-else>
<span class="disabled">暂时还没有填写 IP 地址。</span>
</div>
</td>
</tr>
<tr>
<td colspan="2"><more-options-indicator></more-options-indicator></td>
</tr>
<tbody v-show="moreOptionsVisible">
<tr>
<td>SSH主机地址</td>
<td>
<div v-if="node.login != null && node.login.params != null && node.login.params.host != null">
<span v-if="node.login.params.host.length > 0">{{node.login.params.host}}</span>
<span v-else class="disabled">尚未设置</span>
</div>
<div v-else class="disabled">尚未设置</div>
</td>
</tr>
<tr>
<td>SSH主机端口</td>
<td>
<div v-if="node.login != null && node.login.params != null && node.login.params.host != null">
<span v-if="node.login.params.port > 0">{{node.login.params.port}}</span>
<span v-else class="disabled">尚未设置</span>
</div>
<span v-else class="disabled">尚未设置</span>
</td>
</tr>
<tr>
<td>SSH登录认证</td>
<td>
<div v-if="node.login != null && node.login.grant != null && node.login.grant.id > 0">
<a :href="'/clusters/grants/grant?grantId=' + node.login.grant.id">
{{node.login.grant.name}}
<span class="small grey">{{node.login.grant.methodName}}</span>
<span class="small grey" v-if="node.login.grant.username.length > 0">{{node.login.grant.username}}</span>
</a>
</div>
<span v-else class="disabled">尚未设置</span>
</td>
</tr>
<tr>
<td>API节点地址</td>
<td>
<div v-if="node.apiNodeAddrs != null && node.apiNodeAddrs.length > 0">
<span v-for="addr in node.apiNodeAddrs" class="ui label basic small">{{addr}}</span>
</div>
<span v-else class="disabled">使用全局设置</span>
</td>
</tr>
</tbody>
</table>
<div class="ui divider"></div>
<h3>运行状态</h3>
<table class="ui table definition selectable">
<tr>
<td class="title">运行状态</td>
<td>
<div v-if="node.status.isActive">
<span class="green">运行中</span>&nbsp;
<a href="" @click.prevent="stopNode()" v-if="!isStopping"><span>[通过SSH停止]</span></a>
<span v-if="isStopping">[停止中...]</span>
</div>
<div v-else>
<span class="red">已断开</span>&nbsp;
<a href="" @click.prevent="startNode()" v-if="node.isInstalled && !isStarting"><span>[通过SSH启动]</span></a>
<span v-if="node.isInstalled && isStarting">[启动中...]</span>
<a v-if="!node.isInstalled" :href="'/httpdns/clusters/cluster/node/install?clusterId=' + clusterId + '&nodeId=' + node.id"><span>去安装 &gt;</span></a>
</div>
</td>
</tr>
<tbody v-show="node.status.isActive">
<tr>
<td>CPU用量</td>
<td>
{{node.status.cpuUsageText}}
&nbsp;
<span v-if="node.status.cpuPhysicalCount > 0" class="small grey">{{node.status.cpuPhysicalCount}}核心/{{node.status.cpuLogicalCount}}线程)</span>
</td>
</tr>
<tr>
<td>内存用量</td>
<td>{{node.status.memUsageText}}</td>
</tr>
<tr>
<td>负载</td>
<td>
{{node.status.load1m}} &nbsp; {{node.status.load5m}} &nbsp; {{node.status.load15m}}
&nbsp;
<tip-icon content="三个数字分别代表1分钟、5分钟、15分钟平均负载"></tip-icon>
</td>
</tr>
</tbody>
<tbody>
<tr v-if="node.status.buildVersion != null && node.status.buildVersion.length > 0">
<td>版本</td>
<td>
v{{node.status.buildVersion}}
&nbsp;
<a :href="'/httpdns/clusters/upgradeRemote?clusterId=' + clusterId + '&nodeId=' + node.id" v-if="shouldUpgrade"><span class="red">发现新版本 v{{newVersion}} &raquo;</span></a>
</td>
</tr>
<tr v-if="node.status.exePath != null && node.status.exePath.length > 0">
<td>主程序位置</td>
<td>{{node.status.exePath}}</td>
</tr>
<tr>
<td>最近API连接状况</td>
<td>
<span v-if="node.status.apiSuccessPercent > 0 && node.status.apiAvgCostSeconds > 0">
<span v-if="node.status.apiSuccessPercent <= 50" class="red">连接错误异常严重({{round(100 - node.status.apiSuccessPercent)}}%失败请改善当前节点和API节点之间通讯</span>
<span v-else-if="node.status.apiSuccessPercent <= 80" class="red">连接错误较多({{round(100 - node.status.apiSuccessPercent)}}%失败请改善当前节点和API节点之间通讯</span>
<span v-else-if="node.status.apiSuccessPercent < 100" class="orange">有连接错误发生({{round(100 - node.status.apiSuccessPercent)}}%失败请改善当前节点和API节点之间通讯</span>
<span v-else>
<span v-if="node.status.apiAvgCostSeconds <= 1" class="green">连接良好</span>
<span v-else-if="node.status.apiAvgCostSeconds <= 5" class="orange">连接基本稳定(平均{{round(node.status.apiAvgCostSeconds)}}秒)</span>
<span v-else-if="node.status.apiAvgCostSeconds <= 10" class="orange">连接速度较慢(平均{{round(node.status.apiAvgCostSeconds)}}秒)</span>
<span v-else class="red">连接非常慢(平均{{round(node.status.apiAvgCostSeconds)}}秒请改善当前节点和API节点之间通讯</span>
</span>
</span>
<span v-else class="disabled">尚未上报数据</span>
</td>
</tr>
<tr v-if="nodeDatetime.length > 0">
<td>上次更新时间</td>
<td>
{{nodeDatetime}}
<p class="comment" v-if="nodeTimeDiff > 30"><span class="red">当前节点时间与API节点时间相差 {{nodeTimeDiff}} 秒,请同步节点时间。</span></p>
</td>
</tr>
</tbody>
</table>
<p class="comment" v-if="node.status.isActive">每隔30秒钟更新一次运行状态。</p>
<div class="ui divider"></div>
<h3>安装信息</h3>
<table class="ui table definition selectable">
<tr>
<td>节点ID <em>(id)</em></td>
<td>{{node.uniqueId}}</td>
</tr>
<tr>
<td>密钥 <em>(secret)</em></td>
<td>{{node.secret}}</td>
</tr>
<tr>
<td class="title">安装目录</td>
<td>
<div v-if="node.installDir.length == 0">使用集群设置<span v-if="node.cluster != null && node.cluster.installDir.length > 0">{{node.cluster.installDir}}</span></div>
<span v-else>{{node.installDir}}</span>
</td>
</tr>
<tr>
<td>已安装</td>
<td>
<span v-if="node.isInstalled" class="green">已安装</span>
<a v-else :href="'/httpdns/clusters/cluster/node/install?clusterId=' + clusterId + '&nodeId=' + nodeId" class="underline" title="点击进入安装界面"><span class="red">未安装</span></a>
</td>
</tr>
</table>

View File

@@ -0,0 +1,39 @@
Tea.context(function () {
this.isStarting = false
this.startNode = function () {
this.isStarting = true
this.$post('/httpdns/clusters/cluster/node/start')
.params({
nodeId: this.node.id
})
.success(function () {
teaweb.success('启动成功', function () {
teaweb.reload()
})
})
.done(function () {
this.isStarting = false
})
}
this.isStopping = false
this.stopNode = function () {
this.isStopping = true
this.$post('/httpdns/clusters/cluster/node/stop')
.params({
nodeId: this.node.id
})
.success(function () {
teaweb.success('执行成功', function () {
teaweb.reload()
})
})
.done(function () {
this.isStopping = false
})
}
this.round = function (f) {
return Math.round(f * 100) / 100
}
})

View File

@@ -0,0 +1,3 @@
a.underline {
border-bottom: 1px #db2828 dashed;
}

View File

@@ -0,0 +1,7 @@
.installing-box {
line-height: 1.8;
}
.installing-box .blue {
color: #2185d0;
}
/*# sourceMappingURL=install.css.map */

View File

@@ -0,0 +1 @@
{"version":3,"sources":["install.less"],"names":[],"mappings":"AAAA;EACC,gBAAA;;AADD,eAGC;EACC,cAAA","file":"install.css"}

View File

@@ -0,0 +1,100 @@
{$layout}
{$template "node_menu"}
{$template "/code_editor"}
<!-- 已安装 -->
<div v-if="node.isInstalled">
<div class="ui message green">当前节点为已安装状态。</div>
<a href="" @click.prevent="updateNodeIsInstalled(false)">[重新安装]</a>
<h4>配置文件</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">配置文件</td>
<td>
configs/api_httpdns.yaml &nbsp;
<download-link :v-element="'rpc-code'" :v-file="'api_httpdns.yaml'">[下载]</download-link>
</td>
</tr>
<tr>
<td>配置内容</td>
<td>
<source-code-box id="rpc-code" type="text/yaml">rpc.endpoints: [ {{apiEndpoints}} ]
nodeId: "{{node.uniqueId}}"
secret: "{{node.secret}}"</source-code-box>
<p class="comment">每个节点的配置文件内容均不相同,不能混用。</p>
</td>
</tr>
<tr>
<td class="title">安装目录</td>
<td>
<div v-if="node.installDir.length == 0">使用集群设置<span
v-if="node.cluster != null && node.cluster.installDir.length > 0">{{node.cluster.installDir}}</span>
</div>
<span v-else>{{node.installDir}}</span>
</td>
</tr>
</table>
</div>
<!-- 未安装 -->
<div v-if="!node.isInstalled">
<h4>方法1通过SSH自动安装</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">SSH地址</td>
<td>
<span v-if="sshAddr.length > 0">{{sshAddr}} &nbsp; <a href=""
@click.prevent="showSSHPopup(nodeId)">[修改]</a></span>
<span v-else><span class="red">尚未设置</span> &nbsp; <a href=""
@click.prevent="showSSHPopup(nodeId)">[设置]</a></span>
</td>
</tr>
</table>
<div v-if="installStatus != null && (installStatus.isRunning || installStatus.isFinished)"
class="ui segment installing-box">
<div v-if="installStatus.isRunning" class="blue">安装中...</div>
<div v-if="installStatus.isFinished">
<span v-if="installStatus.isOk" class="green">已安装成功</span>
<span v-if="!installStatus.isOk" class="red">安装过程中发生错误:{{installStatus.error}}</span>
</div>
</div>
<div v-if="installStatus != null && installStatus.isFinished">
<button class="ui button small" type="button" @click.prevent="install()">重新安装</button>
</div>
<div v-if="installStatus == null || (!installStatus.isFinished && !installStatus.isRunning)">
<button class="ui button small primary" type="button" @click.prevent="install()">开始安装</button>
</div>
<h4>方法2手动安装</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">配置文件</td>
<td>
configs/api_httpdns.yaml &nbsp;
<download-link :v-element="'rpc-code'" :v-file="'api_httpdns.yaml'">[下载]</download-link>
</td>
</tr>
<tr>
<td>配置内容</td>
<td>
<source-code-box id="rpc-code" type="text/yaml">rpc.endpoints: [ {{apiEndpoints}} ]
nodeId: "{{node.uniqueId}}"
secret: "{{node.secret}}"</source-code-box>
</td>
</tr>
<tr>
<td class="title">安装目录</td>
<td>
<div v-if="node.installDir.length == 0">使用集群设置<span
v-if="node.cluster != null && node.cluster.installDir.length > 0">{{node.cluster.installDir}}</span>
</div>
<span v-else>{{node.installDir}}</span>
</td>
</tr>
</table>
<a href="" @click.prevent="updateNodeIsInstalled(true)">[修改为已安装状态]</a>
</div>

View File

@@ -0,0 +1,115 @@
Tea.context(function () {
let isInstalling = false
this.$delay(function () {
this.reloadStatus(this.nodeId)
})
this.install = function () {
isInstalling = true
this.$post("$")
.params({
nodeId: this.nodeId
})
.success(function () {})
}
this.updateNodeIsInstalled = function (isInstalled) {
let msg = isInstalled
? "html:确认要将当前节点修改为 <strong>已安装</strong> 状态?"
: "html:确认要将当前节点修改为 <strong>未安装</strong> 状态?"
teaweb.confirm(msg, function () {
this.$post("/httpdns/clusters/cluster/node/updateInstallStatus")
.params({
nodeId: this.nodeId,
isInstalled: isInstalled ? 1 : 0
})
.refresh()
})
}
this.reloadStatus = function (nodeId) {
let that = this
this.$post("/httpdns/clusters/cluster/node/status")
.params({
nodeId: nodeId
})
.success(function (resp) {
this.installStatus = resp.data.installStatus
this.node.isInstalled = resp.data.isInstalled
if (!isInstalling) {
return
}
let currentNodeId = this.node.id
let installStatus = this.installStatus || {}
let errMsg = installStatus.error || ""
let errorCode = installStatus.errorCode || ""
if (errorCode.length > 0) {
isInstalling = false
}
switch (errorCode) {
case "EMPTY_LOGIN":
case "EMPTY_SSH_HOST":
case "EMPTY_SSH_PORT":
case "EMPTY_GRANT":
teaweb.warn("需要补充 SSH 登录信息", function () {
teaweb.popup("/httpdns/clusters/updateNodeSSH?nodeId=" + currentNodeId, {
height: "30em",
callback: function () {
that.install()
}
})
})
return
case "SSH_LOGIN_FAILED":
teaweb.warn("SSH 登录失败,请检查设置", function () {
teaweb.popup("/httpdns/clusters/updateNodeSSH?nodeId=" + currentNodeId, {
height: "30em",
callback: function () {
that.install()
}
})
})
return
case "CREATE_ROOT_DIRECTORY_FAILED":
teaweb.warn("创建根目录失败:" + errMsg)
return
case "INSTALL_HELPER_FAILED":
teaweb.warn("安装助手失败:" + errMsg)
return
case "TEST_FAILED":
teaweb.warn("环境测试失败:" + errMsg)
return
case "RPC_TEST_FAILED":
teaweb.confirm("html:节点到 API 的 RPC 连通性测试失败:" + errMsg + "<br/>现在去修改 API 信息?", function () {
window.location = "/settings/api"
})
return
default:
break
}
})
.done(function () {
this.$delay(function () {
this.reloadStatus(nodeId)
}, 1000)
})
}
this.showSSHPopup = function (nodeId) {
teaweb.popup("/httpdns/clusters/updateNodeSSH?nodeId=" + nodeId, {
height: "30em",
callback: function () {
teaweb.success("保存成功", function () {
teaweb.reload()
})
}
})
}
})

View File

@@ -0,0 +1,7 @@
.installing-box {
line-height: 1.8;
.blue {
color: #2185d0;
}
}

View File

@@ -0,0 +1,5 @@
pre.log-box {
margin: 0;
padding: 0;
}
/*# sourceMappingURL=logs.css.map */

View File

@@ -0,0 +1 @@
{"version":3,"sources":["logs.less"],"names":[],"mappings":"AAAA,GAAG;EACF,SAAA;EACA,UAAA","file":"logs.css"}

View File

@@ -0,0 +1,51 @@
{$layout}
{$template "node_menu"}
{$template "/datepicker"}
<form method="get" action="/httpdns/clusters/cluster/node/logs" class="ui form" autocomplete="off">
<input type="hidden" name="clusterId" :value="clusterId"/>
<input type="hidden" name="nodeId" :value="nodeId"/>
<div class="ui fields inline">
<div class="ui field">
<input type="text" name="dayFrom" placeholder="开始日期" v-model="dayFrom" value="" style="width:8em" id="day-from-picker"/>
</div>
<div class="ui field">
<input type="text" name="dayTo" placeholder="结束日期" v-model="dayTo" value="" style="width:8em" id="day-to-picker"/>
</div>
<div class="ui field">
<select class="ui dropdown" name="level" v-model="level">
<option value="">[级别]</option>
<option value="error">错误</option>
<option value="warning">警告</option>
<option value="info">信息</option>
<option value="success">成功</option>
</select>
</div>
<div class="ui field">
<input type="text" name="keyword" style="width:10em" v-model="keyword" placeholder="关键词"/>
</div>
<div class="ui field">
<button type="submit" class="ui button">查询</button>
</div>
<div class="ui field" v-if="dayFrom.length > 0 || dayTo.length > 0 || keyword.length > 0 || level.length > 0">
<a :href="'/httpdns/clusters/cluster/node/logs?clusterId=' + clusterId + '&nodeId=' + nodeId">[清除条件]</a>
</div>
</div>
</form>
<p class="comment" v-if="logs.length == 0">暂时还没有日志。</p>
<table class="ui table selectable" v-if="logs.length > 0">
<thead>
<tr>
</tr>
</thead>
<tr v-for="log in logs">
<td>
<node-log-row :v-log="log" :v-keyword="keyword"></node-log-row>
</td>
</tr>
</table>
<div class="page" v-html="page"></div>

View File

@@ -0,0 +1,6 @@
Tea.context(function () {
this.$delay(function () {
teaweb.datepicker("day-from-picker")
teaweb.datepicker("day-to-picker")
})
})

Some files were not shown because too many files have changed in this diff Show More