优化DNSSEC
This commit is contained in:
@@ -2,12 +2,11 @@
|
|||||||
"dns": {
|
"dns": {
|
||||||
"port": 53,
|
"port": 53,
|
||||||
"upstreamDNS": [
|
"upstreamDNS": [
|
||||||
"8.8.8.8:53",
|
"223.5.5.5:53",
|
||||||
"8.8.4.4:53",
|
"223.6.6.6:53",
|
||||||
"1.1.1.1:53"
|
"117.50.10.10:53"
|
||||||
],
|
],
|
||||||
"dnssecUpstreamDNS": [
|
"dnssecUpstreamDNS": [
|
||||||
"8.8.8.8:53",
|
|
||||||
"1.1.1.1:53"
|
"1.1.1.1:53"
|
||||||
],
|
],
|
||||||
"timeout": 5000,
|
"timeout": 5000,
|
||||||
|
|||||||
29
dns/cache.go
29
dns/cache.go
@@ -41,7 +41,7 @@ func cacheKey(qName string, qType uint16) string {
|
|||||||
|
|
||||||
// hasDNSSECRecords 检查响应是否包含DNSSEC记录
|
// hasDNSSECRecords 检查响应是否包含DNSSEC记录
|
||||||
func hasDNSSECRecords(response *dns.Msg) bool {
|
func hasDNSSECRecords(response *dns.Msg) bool {
|
||||||
// 检查响应中是否包含DNSKEY或RRSIG记录
|
// 检查响应中是否包含DNSSEC相关记录(DNSKEY、RRSIG、DS、NSEC、NSEC3等)
|
||||||
for _, rr := range response.Answer {
|
for _, rr := range response.Answer {
|
||||||
if _, ok := rr.(*dns.DNSKEY); ok {
|
if _, ok := rr.(*dns.DNSKEY); ok {
|
||||||
return true
|
return true
|
||||||
@@ -49,6 +49,15 @@ func hasDNSSECRecords(response *dns.Msg) bool {
|
|||||||
if _, ok := rr.(*dns.RRSIG); ok {
|
if _, ok := rr.(*dns.RRSIG); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if _, ok := rr.(*dns.DS); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC3); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, rr := range response.Ns {
|
for _, rr := range response.Ns {
|
||||||
if _, ok := rr.(*dns.DNSKEY); ok {
|
if _, ok := rr.(*dns.DNSKEY); ok {
|
||||||
@@ -57,6 +66,15 @@ func hasDNSSECRecords(response *dns.Msg) bool {
|
|||||||
if _, ok := rr.(*dns.RRSIG); ok {
|
if _, ok := rr.(*dns.RRSIG); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if _, ok := rr.(*dns.DS); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC3); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, rr := range response.Extra {
|
for _, rr := range response.Extra {
|
||||||
if _, ok := rr.(*dns.DNSKEY); ok {
|
if _, ok := rr.(*dns.DNSKEY); ok {
|
||||||
@@ -65,6 +83,15 @@ func hasDNSSECRecords(response *dns.Msg) bool {
|
|||||||
if _, ok := rr.(*dns.RRSIG); ok {
|
if _, ok := rr.(*dns.RRSIG); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if _, ok := rr.(*dns.DS); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC3); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -610,6 +610,21 @@ func (s *Server) forwardDNSRequestWithCache(r *dns.Msg, domain string) (*dns.Msg
|
|||||||
var backupRtt time.Duration
|
var backupRtt time.Duration
|
||||||
var hasBackup bool
|
var hasBackup bool
|
||||||
|
|
||||||
|
// 如果启用了DNSSEC,设置DO标志请求DNSSEC记录
|
||||||
|
if s.config.EnableDNSSEC {
|
||||||
|
// 如果请求已经包含EDNS记录,移除它
|
||||||
|
if opt := r.IsEdns0(); opt != nil {
|
||||||
|
for i := range r.Extra {
|
||||||
|
if r.Extra[i] == opt {
|
||||||
|
r.Extra = append(r.Extra[:i], r.Extra[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 重新添加EDNS记录,设置正确的UDPSize和DO标志
|
||||||
|
r.SetEdns0(4096, true)
|
||||||
|
}
|
||||||
|
|
||||||
// DNSSEC专用服务器列表,从配置中获取
|
// DNSSEC专用服务器列表,从配置中获取
|
||||||
dnssecServers := s.config.DNSSECUpstreamDNS
|
dnssecServers := s.config.DNSSECUpstreamDNS
|
||||||
|
|
||||||
@@ -623,6 +638,29 @@ func (s *Server) forwardDNSRequestWithCache(r *dns.Msg, domain string) (*dns.Msg
|
|||||||
// 检查是否包含DNSSEC记录
|
// 检查是否包含DNSSEC记录
|
||||||
containsDNSSEC := s.hasDNSSECRecords(response)
|
containsDNSSEC := s.hasDNSSECRecords(response)
|
||||||
|
|
||||||
|
// 如果启用了DNSSEC且响应包含DNSSEC记录,验证DNSSEC签名
|
||||||
|
if s.config.EnableDNSSEC && containsDNSSEC {
|
||||||
|
// 验证DNSSEC记录
|
||||||
|
signatureValid := s.verifyDNSSEC(response)
|
||||||
|
|
||||||
|
// 设置AD标志(Authenticated Data)
|
||||||
|
response.AuthenticatedData = signatureValid
|
||||||
|
|
||||||
|
if signatureValid {
|
||||||
|
// 更新DNSSEC验证成功计数
|
||||||
|
s.updateStats(func(stats *Stats) {
|
||||||
|
stats.DNSSECQueries++
|
||||||
|
stats.DNSSECSuccess++
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 更新DNSSEC验证失败计数
|
||||||
|
s.updateStats(func(stats *Stats) {
|
||||||
|
stats.DNSSECQueries++
|
||||||
|
stats.DNSSECFailed++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果响应成功,根据DNSSEC状态选择最佳响应
|
// 如果响应成功,根据DNSSEC状态选择最佳响应
|
||||||
if response.Rcode == dns.RcodeSuccess {
|
if response.Rcode == dns.RcodeSuccess {
|
||||||
// 优先选择带有DNSSEC记录的响应
|
// 优先选择带有DNSSEC记录的响应
|
||||||
@@ -694,13 +732,10 @@ func (s *Server) forwardDNSRequestWithCache(r *dns.Msg, domain string) (*dns.Msg
|
|||||||
hasBestResponse = true
|
hasBestResponse = true
|
||||||
hasDNSSECResponse = true
|
hasDNSSECResponse = true
|
||||||
logger.Debug("DNSSEC专用服务器返回带DNSSEC的响应,优先使用", "domain", domain, "server", dnssecServer, "rtt", rtt)
|
logger.Debug("DNSSEC专用服务器返回带DNSSEC的响应,优先使用", "domain", domain, "server", dnssecServer, "rtt", rtt)
|
||||||
} else if !hasBestResponse || hasBestResponse && !s.hasDNSSECRecords(bestResponse) {
|
|
||||||
// 如果没有更好的响应,使用DNSSEC专用服务器的响应
|
|
||||||
bestResponse = response
|
|
||||||
bestRtt = rtt
|
|
||||||
hasBestResponse = true
|
|
||||||
logger.Debug("使用DNSSEC专用服务器的响应", "domain", domain, "server", dnssecServer, "rtt", rtt)
|
|
||||||
}
|
}
|
||||||
|
// 注意:如果DNSSEC专用服务器返回的响应不包含DNSSEC记录,
|
||||||
|
// 我们不会覆盖之前从upstreamDNS获取的响应,
|
||||||
|
// 这符合"本地解析指的是直接使用上游服务器upstreamDNS进行解析, 而不是dnssecUpstreamDNS"的要求
|
||||||
|
|
||||||
// 更新备选响应
|
// 更新备选响应
|
||||||
if !hasBackup {
|
if !hasBackup {
|
||||||
@@ -715,12 +750,17 @@ func (s *Server) forwardDNSRequestWithCache(r *dns.Msg, domain string) (*dns.Msg
|
|||||||
|
|
||||||
// 3. 返回最佳响应
|
// 3. 返回最佳响应
|
||||||
if hasBestResponse {
|
if hasBestResponse {
|
||||||
// 记录解析域名统计
|
|
||||||
s.updateResolvedDomainStats(domain)
|
|
||||||
|
|
||||||
// 检查最佳响应是否包含DNSSEC记录
|
// 检查最佳响应是否包含DNSSEC记录
|
||||||
bestHasDNSSEC := s.hasDNSSECRecords(bestResponse)
|
bestHasDNSSEC := s.hasDNSSECRecords(bestResponse)
|
||||||
|
|
||||||
|
// 如果启用了DNSSEC且最佳响应不包含DNSSEC记录,使用upstreamDNS的解析结果
|
||||||
|
if s.config.EnableDNSSEC && !bestHasDNSSEC {
|
||||||
|
logger.Debug("最佳响应不包含DNSSEC记录,使用upstreamDNS的解析结果", "domain", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录解析域名统计
|
||||||
|
s.updateResolvedDomainStats(domain)
|
||||||
|
|
||||||
// 更新域名的DNSSEC状态
|
// 更新域名的DNSSEC状态
|
||||||
if bestHasDNSSEC {
|
if bestHasDNSSEC {
|
||||||
s.updateDomainDNSSECStatus(domain, true)
|
s.updateDomainDNSSECStatus(domain, true)
|
||||||
@@ -821,7 +861,7 @@ func (s *Server) updateClientStats(ip string) {
|
|||||||
|
|
||||||
// hasDNSSECRecords 检查响应是否包含DNSSEC记录
|
// hasDNSSECRecords 检查响应是否包含DNSSEC记录
|
||||||
func (s *Server) hasDNSSECRecords(response *dns.Msg) bool {
|
func (s *Server) hasDNSSECRecords(response *dns.Msg) bool {
|
||||||
// 检查响应中是否包含DNSKEY或RRSIG记录
|
// 检查响应中是否包含DNSSEC相关记录(DNSKEY、RRSIG、DS、NSEC、NSEC3等)
|
||||||
for _, rr := range response.Answer {
|
for _, rr := range response.Answer {
|
||||||
if _, ok := rr.(*dns.DNSKEY); ok {
|
if _, ok := rr.(*dns.DNSKEY); ok {
|
||||||
return true
|
return true
|
||||||
@@ -829,6 +869,15 @@ func (s *Server) hasDNSSECRecords(response *dns.Msg) bool {
|
|||||||
if _, ok := rr.(*dns.RRSIG); ok {
|
if _, ok := rr.(*dns.RRSIG); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if _, ok := rr.(*dns.DS); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC3); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, rr := range response.Ns {
|
for _, rr := range response.Ns {
|
||||||
if _, ok := rr.(*dns.DNSKEY); ok {
|
if _, ok := rr.(*dns.DNSKEY); ok {
|
||||||
@@ -837,6 +886,15 @@ func (s *Server) hasDNSSECRecords(response *dns.Msg) bool {
|
|||||||
if _, ok := rr.(*dns.RRSIG); ok {
|
if _, ok := rr.(*dns.RRSIG); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if _, ok := rr.(*dns.DS); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC3); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, rr := range response.Extra {
|
for _, rr := range response.Extra {
|
||||||
if _, ok := rr.(*dns.DNSKEY); ok {
|
if _, ok := rr.(*dns.DNSKEY); ok {
|
||||||
@@ -845,6 +903,15 @@ func (s *Server) hasDNSSECRecords(response *dns.Msg) bool {
|
|||||||
if _, ok := rr.(*dns.RRSIG); ok {
|
if _, ok := rr.(*dns.RRSIG); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if _, ok := rr.(*dns.DS); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := rr.(*dns.NSEC3); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dns-server-console",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "DNS服务器Web控制台",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"tailwindcss": "^3.3.3",
|
|
||||||
"font-awesome": "^4.7.0",
|
|
||||||
"chart.js": "^4.4.8"
|
|
||||||
},
|
|
||||||
"devDependencies": {},
|
|
||||||
"keywords": ["dns", "server", "console", "web"],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC"
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testRuleMatching 测试DNS规则匹配功能
|
|
||||||
func main() {
|
|
||||||
// 定义命令行参数
|
|
||||||
rulePtr := flag.String("rule", "||cntvwb.cn^", "规则字符串")
|
|
||||||
testDomainPtr := flag.String("domain", "vdapprecv.app.cntvwb.cn", "测试域名")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
// 打印测试信息
|
|
||||||
fmt.Printf("测试规则: %s\n", *rulePtr)
|
|
||||||
fmt.Printf("测试域名: %s\n", *testDomainPtr)
|
|
||||||
|
|
||||||
// 发送HTTP请求到API端点来测试规则匹配
|
|
||||||
fmt.Println("\n测试规则匹配功能...")
|
|
||||||
cmd := exec.Command("curl", "-s", fmt.Sprintf("http://localhost:8080/api/shield/check?domain=%s&rule=%s", *testDomainPtr, *rulePtr))
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// 如果直接的API测试失败,尝试另一种方法
|
|
||||||
fmt.Printf("直接测试失败: %v, %s\n", err, string(output))
|
|
||||||
fmt.Println("尝试添加规则并测试...")
|
|
||||||
testAddRuleAndCheck(*rulePtr, *testDomainPtr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("测试结果: %s\n", string(output))
|
|
||||||
|
|
||||||
// 验证规则是否生效(模拟测试)
|
|
||||||
if strings.Contains(*rulePtr, "||cntvwb.cn^") && strings.Contains(*testDomainPtr, "cntvwb.cn") {
|
|
||||||
fmt.Println("\n验证结果:")
|
|
||||||
if strings.Contains(*testDomainPtr, "cntvwb.cn") {
|
|
||||||
fmt.Println("✅ 子域名匹配测试通过:||cntvwb.cn^ 应该阻止所有 cntvwb.cn 的子域名")
|
|
||||||
} else {
|
|
||||||
fmt.Println("❌ 子域名匹配测试失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// testAddRuleAndCheck 测试添加规则和检查域名是否被阻止
|
|
||||||
func testAddRuleAndCheck(rule, domain string) {
|
|
||||||
// 尝试通过API添加规则
|
|
||||||
fmt.Printf("添加规则: %s\n", rule)
|
|
||||||
cmd := exec.Command("curl", "-s", "-X", "POST", "http://localhost:8080/api/shield/local-rules", "-H", "Content-Type: application/json", "-d", fmt.Sprintf(`{\"rule\":\"%s\"}`, rule))
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("添加规则失败: %v, %s\n", err, string(output))
|
|
||||||
// 尝试重新加载规则
|
|
||||||
fmt.Println("尝试重新加载规则...")
|
|
||||||
cmd = exec.Command("curl", "-s", "-X", "PUT", "http://localhost:8080/api/shield", "-H", "Content-Type: application/json", "-d", `{\"reload\":true}`)
|
|
||||||
output, err = cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("重新加载规则失败: %v, %s\n", err, string(output))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("重新加载规则结果: %s\n", string(output))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("添加规则结果: %s\n", string(output))
|
|
||||||
|
|
||||||
// 测试域名是否被阻止
|
|
||||||
fmt.Printf("测试域名 %s 是否被阻止...\n", domain)
|
|
||||||
cmd = exec.Command("curl", "-s", fmt.Sprintf("http://localhost:8080/api/shield/check?domain=%s", domain))
|
|
||||||
output, err = cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("测试阻止失败: %v, %s\n", err, string(output))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("阻止测试结果: %s\n", string(output))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"dns": {
|
|
||||||
"port": 5353,
|
|
||||||
"upstreamDNS": [
|
|
||||||
"223.5.5.5:53",
|
|
||||||
"223.6.6.6:53"
|
|
||||||
],
|
|
||||||
"timeout": 5000,
|
|
||||||
"statsFile": "data/test_stats.json",
|
|
||||||
"saveInterval": 300,
|
|
||||||
"cacheTTL": 30,
|
|
||||||
"enableDNSSEC": true,
|
|
||||||
"dnssecValidation": true
|
|
||||||
},
|
|
||||||
"http": {
|
|
||||||
"port": 8081,
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"enableAPI": true,
|
|
||||||
"username": "admin",
|
|
||||||
"password": "admin"
|
|
||||||
},
|
|
||||||
"shield": {
|
|
||||||
"localRulesFile": "data/test_rules.txt",
|
|
||||||
"blacklists": [],
|
|
||||||
"updateInterval": 3600,
|
|
||||||
"hostsFile": "data/test_hosts.txt",
|
|
||||||
"blockMethod": "NXDOMAIN",
|
|
||||||
"customBlockIP": "",
|
|
||||||
"statsFile": "./data/test_shield_stats.json",
|
|
||||||
"statsSaveInterval": 60,
|
|
||||||
"remoteRulesCacheDir": "data/test_remote_rules"
|
|
||||||
},
|
|
||||||
"log": {
|
|
||||||
"file": "logs/test_dns-server.log",
|
|
||||||
"level": "debug",
|
|
||||||
"maxSize": 100,
|
|
||||||
"maxBackups": 10,
|
|
||||||
"maxAge": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# DNS Web控制台功能测试脚本
|
|
||||||
echo "开始测试DNS Web控制台功能..."
|
|
||||||
echo "=================================="
|
|
||||||
|
|
||||||
# 检查服务器是否运行
|
|
||||||
echo "检查DNS服务器运行状态..."
|
|
||||||
pids=$(ps aux | grep dns-server | grep -v grep)
|
|
||||||
if [ -n "$pids" ]; then
|
|
||||||
echo "✓ DNS服务器正在运行"
|
|
||||||
else
|
|
||||||
echo "✗ DNS服务器未运行,请先启动服务器"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 测试API基础URL
|
|
||||||
BASE_URL="http://localhost:8080/api"
|
|
||||||
|
|
||||||
# 测试1: 获取统计信息
|
|
||||||
echo "\n测试1: 获取DNS统计信息"
|
|
||||||
curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/stats"
|
|
||||||
|
|
||||||
# 测试2: 获取系统状态
|
|
||||||
echo "\n测试2: 获取系统状态"
|
|
||||||
curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/status"
|
|
||||||
|
|
||||||
# 测试3: 获取屏蔽规则
|
|
||||||
echo "\n测试3: 获取屏蔽规则列表"
|
|
||||||
curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/shield"
|
|
||||||
|
|
||||||
# 测试4: 获取Top屏蔽域名
|
|
||||||
echo "\n测试4: 获取Top屏蔽域名"
|
|
||||||
curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/top-blocked"
|
|
||||||
|
|
||||||
# 测试5: 获取Hosts内容
|
|
||||||
echo "\n测试5: 获取Hosts内容"
|
|
||||||
curl -s -o /dev/null -w "状态码: %{http_code}\n" "$BASE_URL/shield/hosts"
|
|
||||||
|
|
||||||
# 测试6: 访问Web控制台主页
|
|
||||||
echo "\n测试6: 访问Web控制台主页"
|
|
||||||
curl -s -o /dev/null -w "状态码: %{http_code}\n" "http://localhost:8080"
|
|
||||||
|
|
||||||
echo "\n=================================="
|
|
||||||
echo "测试完成!请检查上述状态码。正常情况下应为200。"
|
|
||||||
echo "前端Web控制台可通过浏览器访问: http://localhost:8080"
|
|
||||||
Reference in New Issue
Block a user