管理端全部功能跑通

This commit is contained in:
robin
2026-02-27 10:35:22 +08:00
parent 4d275c921d
commit 150799f41d
263 changed files with 22664 additions and 4053 deletions

5
EdgeHttpDNS/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*.zip
edge-dns
configs/
logs/
data/

View File

@@ -2,7 +2,7 @@
## 0. 文档信息
- 目标文件:`EdgeHttpDNS/HTTPDNS后端开发计划.md`
- 交付范围:`EdgeAdmin + EdgeAPI + EdgeNode + SDK对接接口`
- 交付范围:`EdgeAdmin + EdgeAPI + EdgeHttpDNS + SDK对接接口`
- 交付策略:一次性全量交付(非分阶段)
- 核心约束:
- 仅新协议
@@ -12,6 +12,7 @@
- 独立 `edgeHTTPDNS*` 数据表
- 新增独立节点角色 `NodeRoleHTTPDNS`
- 访问日志 MySQL + ClickHouse 双写(查询优先 ClickHouse
- 不复用智能DNSEdgeDNS/NS模块的 DAO/Service/StoreHTTPDNS 独立实现(可参考并复制所需能力)
## 1. 目标与成功标准
1. 将 HTTPDNS 从当前 Admin 侧 mock/store 方案落地为真实后端能力。
@@ -26,13 +27,13 @@
- 访问日志与运行日志可查询、可筛选、可分页
- 节点配置与状态可下发和回传
- 主备集群服务域名可在应用设置中配置并生效
- EdgeNode 支持 SNI 与 Host 解耦路由,并可执行 WAF 动态验签与隐匿 SNI 转发
- EdgeHttpDNS 支持 SNI 与 Host 解耦路由,并可执行 WAF 动态验签与隐匿 SNI 转发
## 2. 架构与边界
### 2.1 服务边界
1. EdgeAdmin仅负责页面动作与 RPC 编排,不存业务状态。
2. EdgeAPI负责数据存储、策略匹配、接口服务、日志汇聚。
3. EdgeNodeHTTPDNS节点负责执行解析、接收策略任务、上报运行日志/访问日志,并执行 SNI/Host 解耦路由、WAF 动态验签、隐匿 SNI 转发
3. EdgeHttpDNSHTTPDNS节点负责执行解析、上报运行日志/访问日志、接收任务
4. SDK手动配置应用关联主备服务域名调用 `/resolve` 获取结果。
### 2.2 不做项
@@ -85,13 +86,13 @@
- 日志:访问日志分页查询、运行日志分页查询
- 测试:在线解析测试调用(入参包含 appId、clusterId、domain、qtype、clientIp
### 3.3 节点日志上报 RPCEdgeNode -> EdgeAPI
### 3.3 节点日志上报 RPCEdgeHttpDNS -> EdgeAPI
1. `CreateHTTPDNSAccessLogs`(批量)
2. `CreateHTTPDNSRuntimeLogs`(批量)
3. 幂等键:`requestId + nodeId`
4. 支持高吞吐批量提交和失败重试
### 3.4 节点路由与 WAF 策略下发契约EdgeAPI -> EdgeNode
### 3.4 节点路由与 WAF 策略下发契约EdgeAPI -> EdgeHttpDNS
1. 下发内容最小集合:
- `appId/domain/serviceDomain`
- `sniMode`(固定为隐匿 SNI
@@ -168,7 +169,7 @@
- 规则变更
- 证书变更
- 路由与 WAF 策略变更
3. EdgeNode 增加 HTTPDNS 子服务:
3. EdgeHttpDNS 增加 HTTPDNS 子服务:
- 接收配置快照
- 执行解析
- 上报运行/访问日志
@@ -215,7 +216,7 @@
2. `/resolve` 各错误码分支
3. 节点日志上报双写MySQL+CH
4. CH 不可用时 MySQL 回退查询
5. EdgeAPI 策略下发 -> EdgeNode 路由/WAF 执行 -> 日志落库全链路
5. EdgeAPI 策略下发 -> EdgeHttpDNS 路由/WAF 执行 -> 日志落库全链路
### 9.3 回归测试
1. 智能DNS功能不受影响
@@ -228,14 +229,14 @@
3. 自定义解析按线路返回预期 IP
4. 访问日志筛选与概要展示正确
5. 运行日志级别/字段与智能DNS一致
6. EdgeNode 可在 SNI 与 Host 解耦场景下正确路由到目标应用
6. EdgeHttpDNS 可在 SNI 与 Host 解耦场景下正确路由到目标应用
7. 开启 WAF 动态验签后,合法请求通过、非法签名请求被拒绝且有审计日志
## 10. 发布与回滚
1. 发布顺序:
- DB migration
- EdgeAPIDAO+RPC+resolve
- EdgeNode(角色+上报)
- EdgeHttpDNS(角色+上报)
- EdgeAdminRPC切换
2. 开关控制:
- `httpdns.resolve.enabled`

View File

@@ -0,0 +1,92 @@
# SNI隐匿开发计划HTTPDNS专项
## 0. 目标
在明文网络层DNS 与 TLS ClientHello中不出现真实业务域名真实业务域名仅出现在加密后的 HTTP 层Host / :authority中传输。
## 1. 固定原则
1. 只做 SNI 隐匿能力,不引入其他无关能力描述。
2. 客户端不走系统 DNS 解析业务域名。
3. TLS 握手阶段不发送真实业务域名 SNI。
4. CDN/WAF 节点必须支持“空SNI接入 + Host路由”。
5. 真实域名只在 HTTPS 加密通道内携带。
## 2. 端到端链路(目标形态)
1. App 调用 SDK请求解析业务域名。
2. SDK 从 HTTPDNS 获取业务域名对应的“接入 IP 列表”(不是业务域名 DNS
3. SDK 直连接入 IP 发起 TLS
- SNI 置空(或不发送)
- 不出现真实业务域名
4. TLS 建立后发起 HTTPS 请求:
- Host / :authority = 真实业务域名
5. CDN/WAF 节点在解密后读取 Host将流量路由到对应业务源站。
## 3. SDK 改造要求
### 3.1 连接行为
1. 提供“按 IP 直连”的请求通道。
2. TLS 握手时固定空 SNI不允许带真实域名。
3. HTTP 层强制写入真实 Host / :authority。
### 3.2 证书校验
1. 仍必须做证书链校验(不允许关闭 TLS 安全校验)。
2. 证书校验目标为接入层证书CDN/WAF 对外证书),而非业务源站证书。
### 3.3 多IP与容错
1. HTTPDNS 返回多个接入 IP 时SDK 按顺序/策略重试。
2. 连接失败可切换下一 IP。
3. 缓存与过期策略保持稳定,避免频繁抖动。
### 3.4 多端一致性
1. Android/iOS/Flutter 需保证一致行为:
- 空 SNI
- Host 注入
- 失败重试策略
2. 文档与示例代码同步更新。
## 4. CDN/WAF 节点改造要求
### 4.1 TLS 接入
1. 支持无 SNI ClientHello 的 TLS 握手。
2. 为接入域名部署有效证书(覆盖客户端连接目标)。
### 4.2 路由逻辑
1. 以 Host / :authority 作为业务路由主键。
2. 路由匹配前做标准化:
- 小写化
- 去端口
3. Host 未命中时返回明确错误4xx禁止兜底到默认站点。
### 4.3 回源行为
1. 节点到源站可继续使用 HTTPS 回源。
2. 回源主机名与证书校验按现有网关策略执行。
### 4.4 可观测性
1. 增加日志字段:
- `tlsSniPresent`(是否携带 SNI
- `host`
- `routeResult`
2. 可按“空SNI请求占比、Host路由命中率”监控。
## 5. 控制面(管理端)要求
1. 页面仅展示“已启用 SNI 隐匿空SNI不提供策略切换。
2. 集群侧需可检查“节点是否支持空SNI接入”。
3. 发布配置时支持灰度与回滚。
## 6. 验收标准
1. 抓包验证:
- DNS 明文流量中不出现真实业务域名
- TLS ClientHello 中不出现真实业务域名 SNI
2. 请求验证:
- HTTPS 请求 Host 为真实业务域名
- CDN/WAF 按 Host 正确路由
3. 稳定性验证:
- 多 IP 切换成功
- 节点故障时请求可恢复
## 7. 上线顺序
1. 先升级 CDN/WAF 节点能力空SNI接入 + Host路由
2. 再升级 SDK空SNI + Host注入
3. 最后按应用灰度开启,观察指标后全量。
## 8. 风险与约束
1. 若 CDN/WAF 不支持空 SNI链路会在握手阶段失败。
2. 若 Host 路由不严格,可能出现串站风险。
3. 若客户端错误关闭证书校验,会引入严重安全风险。

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
./build.sh linux amd64
# ./build.sh linux arm64
# ./build.sh darwin amd64
# ./build.sh darwin arm64

150
EdgeHttpDNS/build/build.sh Normal file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -e
function build() {
ROOT=$(dirname "$0")
NAME="edge-httpdns"
VERSION=$(lookup-version "$ROOT"/../internal/const/const.go)
DIST=$ROOT/"../dist/${NAME}"
OS=${1}
ARCH=${2}
if [ -z "$OS" ]; then
echo "usage: build.sh OS ARCH"
exit 1
fi
if [ -z "$ARCH" ]; then
echo "usage: build.sh OS ARCH"
exit 1
fi
ZIP_PATH=$(which zip)
if [ -z "$ZIP_PATH" ]; then
echo "we need 'zip' command to compress files"
exit 1
fi
echo "building v${VERSION}/${OS}/${ARCH} ..."
ZIP="${NAME}-${OS}-${ARCH}-v${VERSION}.zip"
rm -rf "$DIST"
mkdir -p "$DIST"/bin
mkdir -p "$DIST"/configs
mkdir -p "$DIST"/logs
mkdir -p "$DIST"/data
cp "$ROOT"/configs/api_httpdns.template.yaml "$DIST"/configs
copy_fluent_bit_assets "$ROOT" "$DIST" "$OS" "$ARCH" || exit 1
env GOOS="${OS}" GOARCH="${ARCH}" CGO_ENABLED=1 \
go build -trimpath -o "$DIST"/bin/${NAME} -ldflags="-s -w" "$ROOT"/../cmd/edge-httpdns/main.go
if [ ! -f "$DIST"/bin/${NAME} ]; then
echo "build failed!"
exit 1
fi
# delete hidden files
find "$DIST" -name ".DS_Store" -delete
find "$DIST" -name ".gitignore" -delete
echo "zip files ..."
cd "${DIST}/../" || exit 1
if [ -f "${ZIP}" ]; then
rm -f "${ZIP}"
fi
zip -r -X -q "${ZIP}" ${NAME}/
rm -rf "${NAME}"
cd - || exit 1
echo "OK"
}
function copy_fluent_bit_assets() {
ROOT=$1
DIST=$2
OS=$3
ARCH=$4
FLUENT_ROOT="$ROOT/../../deploy/fluent-bit"
FLUENT_DIST="$DIST/deploy/fluent-bit"
if [ ! -d "$FLUENT_ROOT" ]; then
echo "[error] fluent-bit source directory not found: $FLUENT_ROOT"
return 1
fi
verify_fluent_bit_package_matrix "$FLUENT_ROOT" "$ARCH" || return 1
rm -rf "$FLUENT_DIST"
mkdir -p "$FLUENT_DIST"
for file in fluent-bit.conf fluent-bit-dns.conf fluent-bit-https.conf fluent-bit-dns-https.conf fluent-bit-windows.conf fluent-bit-windows-https.conf parsers.conf clickhouse-upstream.conf clickhouse-upstream-windows.conf README.md; do
if [ -f "$FLUENT_ROOT/$file" ]; then
cp "$FLUENT_ROOT/$file" "$FLUENT_DIST/"
fi
done
if [ "$OS" = "linux" ]; then
PACKAGE_SRC="$FLUENT_ROOT/packages/linux-$ARCH"
PACKAGE_DST="$FLUENT_DIST/packages/linux-$ARCH"
if [ -d "$PACKAGE_SRC" ]; then
mkdir -p "$PACKAGE_DST"
cp -R "$PACKAGE_SRC/." "$PACKAGE_DST/"
else
echo "[error] fluent-bit package directory not found: $PACKAGE_SRC"
return 1
fi
fi
rm -f "$FLUENT_DIST/.gitignore"
rm -f "$FLUENT_DIST"/logs.db*
rm -rf "$FLUENT_DIST/storage"
return 0
}
function verify_fluent_bit_package_matrix() {
FLUENT_ROOT=$1
ARCH=$2
REQUIRED_FILES=()
if [ "$ARCH" = "amd64" ]; then
REQUIRED_FILES=(
"packages/linux-amd64/fluent-bit_4.2.2_amd64.deb"
"packages/linux-amd64/fluent-bit-4.2.2-1.x86_64.rpm"
)
elif [ "$ARCH" = "arm64" ]; then
REQUIRED_FILES=(
"packages/linux-arm64/fluent-bit_4.2.2_arm64.deb"
"packages/linux-arm64/fluent-bit-4.2.2-1.aarch64.rpm"
)
else
echo "[error] unsupported arch for fluent-bit package validation: $ARCH"
return 1
fi
MISSING=0
for FILE in "${REQUIRED_FILES[@]}"; do
if [ ! -f "$FLUENT_ROOT/$FILE" ]; then
echo "[error] fluent-bit matrix package missing: $FLUENT_ROOT/$FILE"
MISSING=1
fi
done
if [ "$MISSING" -ne 0 ]; then
return 1
fi
return 0
}
function lookup-version() {
FILE=$1
VERSION_DATA=$(cat "$FILE")
re="Version[ ]+=[ ]+\"([0-9.]+)\""
if [[ $VERSION_DATA =~ $re ]]; then
echo "${BASH_REMATCH[1]}"
else
echo "could not match version"
exit 1
fi
}
build "$1" "$2"

View File

@@ -0,0 +1,43 @@
package main
import (
"fmt"
"os"
"github.com/TeaOSLab/EdgeHttpDNS/internal/apps"
"github.com/TeaOSLab/EdgeHttpDNS/internal/configs"
teaconst "github.com/TeaOSLab/EdgeHttpDNS/internal/const"
"github.com/TeaOSLab/EdgeHttpDNS/internal/nodes"
)
func main() {
app := apps.NewAppCmd().
Version(teaconst.Version).
Product(teaconst.ProductName).
Usage(teaconst.ProcessName + " [-v|start|stop|restart|status|service|daemon]")
app.On("start:before", func() {
_, err := configs.LoadAPIConfig()
if err != nil {
fmt.Println("[ERROR]start failed: load config from '" + configs.ConfigFileName + "' failed: " + err.Error())
os.Exit(1)
}
})
app.On("daemon", func() {
nodes.NewHTTPDNSNode().Daemon()
})
app.On("service", func() {
err := nodes.NewHTTPDNSNode().InstallSystemService()
if err != nil {
fmt.Println("[ERROR]install failed: " + err.Error())
return
}
fmt.Println("done")
})
app.Run(func() {
nodes.NewHTTPDNSNode().Run()
})
}

Binary file not shown.

27
EdgeHttpDNS/go.mod Normal file
View File

@@ -0,0 +1,27 @@
module github.com/TeaOSLab/EdgeHttpDNS
go 1.25
replace github.com/TeaOSLab/EdgeCommon => ../EdgeCommon
require (
github.com/TeaOSLab/EdgeCommon v0.0.0-00010101000000-000000000000
github.com/iwind/TeaGo v0.0.0-20240411075713-6c1fc9aca7b6
github.com/iwind/gosock v0.0.0-20211103081026-ee4652210ca4
google.golang.org/grpc v1.78.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/miekg/dns v1.1.72 // indirect
github.com/oschwald/geoip2-golang v1.13.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

67
EdgeHttpDNS/go.sum Normal file
View File

@@ -0,0 +1,67 @@
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/iwind/TeaGo v0.0.0-20240411075713-6c1fc9aca7b6 h1:dS3pTxrLlDQxdoxSUcHkHnr3LHpsBIXv8v2/xw65RN8=
github.com/iwind/TeaGo v0.0.0-20240411075713-6c1fc9aca7b6/go.mod h1:SfqVbWyIPdVflyA6lMgicZzsoGS8pyeLiTRe8/CIpGI=
github.com/iwind/gosock v0.0.0-20211103081026-ee4652210ca4 h1:VWGsCqTzObdlbf7UUE3oceIpcEKi4C/YBUszQXk118A=
github.com/iwind/gosock v0.0.0-20211103081026-ee4652210ca4/go.mod h1:H5Q7SXwbx3a97ecJkaS2sD77gspzE7HFUafBO0peEyA=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,149 @@
package apps
import (
"fmt"
"os"
"os/exec"
"runtime"
"time"
teaconst "github.com/TeaOSLab/EdgeHttpDNS/internal/const"
"github.com/iwind/TeaGo/maps"
"github.com/iwind/gosock/pkg/gosock"
)
type AppCmd struct {
product string
version string
usage string
directives map[string]func()
sock *gosock.Sock
}
func NewAppCmd() *AppCmd {
return &AppCmd{
directives: map[string]func(){},
sock: gosock.NewTmpSock(teaconst.ProcessName),
}
}
func (a *AppCmd) Product(product string) *AppCmd {
a.product = product
return a
}
func (a *AppCmd) Version(version string) *AppCmd {
a.version = version
return a
}
func (a *AppCmd) Usage(usage string) *AppCmd {
a.usage = usage
return a
}
func (a *AppCmd) On(arg string, callback func()) {
a.directives[arg] = callback
}
func (a *AppCmd) Run(main func()) {
args := os.Args[1:]
if len(args) == 0 {
main()
return
}
switch args[0] {
case "-v", "version", "-version", "--version":
fmt.Println(a.product+" v"+a.version, "(build:", runtimeString()+")")
return
case "help", "-h", "--help":
fmt.Println(a.product + " v" + a.version)
fmt.Println("Usage:")
fmt.Println(" " + a.usage)
return
case "start":
a.runDirective("start:before")
a.runStart()
return
case "stop":
a.runStop()
return
case "restart":
a.runStop()
time.Sleep(1 * time.Second)
a.runDirective("start:before")
a.runStart()
return
case "status":
a.runStatus()
return
default:
if callback, ok := a.directives[args[0]]; ok {
callback()
return
}
fmt.Println("unknown command '" + args[0] + "'")
}
}
func (a *AppCmd) runStart() {
pid := a.getPID()
if pid > 0 {
fmt.Println(a.product+" already started, pid:", pid)
return
}
cmd := exec.Command(os.Args[0])
cmd.Env = append(os.Environ(), "EdgeBackground=on")
err := cmd.Start()
if err != nil {
fmt.Println(a.product+" start failed:", err.Error())
return
}
fmt.Println(a.product+" started, pid:", cmd.Process.Pid)
}
func (a *AppCmd) runStop() {
pid := a.getPID()
if pid == 0 {
fmt.Println(a.product + " not started")
return
}
_, _ = a.sock.Send(&gosock.Command{Code: "stop"})
fmt.Println(a.product+" stopped, pid:", pid)
}
func (a *AppCmd) runStatus() {
pid := a.getPID()
if pid == 0 {
fmt.Println(a.product + " not started")
return
}
fmt.Println(a.product+" is running, pid:", pid)
}
func (a *AppCmd) runDirective(name string) {
if callback, ok := a.directives[name]; ok && callback != nil {
callback()
}
}
func (a *AppCmd) getPID() int {
if !a.sock.IsListening() {
return 0
}
reply, err := a.sock.Send(&gosock.Command{Code: "pid"})
if err != nil {
return 0
}
return maps.NewMap(reply.Params).GetInt("pid")
}
func runtimeString() string {
return runtime.GOOS + "/" + runtime.GOARCH
}

View File

@@ -0,0 +1,23 @@
package teaconst
const (
Version = "1.4.8"
ProductName = "Edge HTTPDNS"
ProcessName = "edge-httpdns"
ProductNameZH = "Edge HTTPDNS"
Role = "httpdns"
EncryptKey = "8f983f4d69b83aaa0d74b21a212f6967"
EncryptMethod = "aes-256-cfb"
SystemdServiceName = "edge-httpdns"
// HTTPDNS node tasks from API.
TaskTypeHTTPDNSConfigChanged = "httpdnsConfigChanged"
TaskTypeHTTPDNSAppChanged = "httpdnsAppChanged"
TaskTypeHTTPDNSDomainChanged = "httpdnsDomainChanged"
TaskTypeHTTPDNSRuleChanged = "httpdnsRuleChanged"
TaskTypeHTTPDNSTLSChanged = "httpdnsTLSChanged"
)

View File

@@ -0,0 +1,7 @@
package encrypt
type MethodInterface interface {
Init(key []byte, iv []byte) error
Encrypt(src []byte) (dst []byte, err error)
Decrypt(dst []byte) (src []byte, err error)
}

View File

@@ -0,0 +1,64 @@
package encrypt
import (
"bytes"
"crypto/aes"
"crypto/cipher"
)
type AES256CFBMethod struct {
block cipher.Block
iv []byte
}
func (m *AES256CFBMethod) Init(key, iv []byte) error {
keyLen := len(key)
if keyLen > 32 {
key = key[:32]
} else if keyLen < 32 {
key = append(key, bytes.Repeat([]byte{' '}, 32-keyLen)...)
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
m.block = block
ivLen := len(iv)
if ivLen > aes.BlockSize {
iv = iv[:aes.BlockSize]
} else if ivLen < aes.BlockSize {
iv = append(iv, bytes.Repeat([]byte{' '}, aes.BlockSize-ivLen)...)
}
m.iv = iv
return nil
}
func (m *AES256CFBMethod) Encrypt(src []byte) (dst []byte, err error) {
if len(src) == 0 {
return
}
defer func() {
err = RecoverMethodPanic(recover())
}()
dst = make([]byte, len(src))
cipher.NewCFBEncrypter(m.block, m.iv).XORKeyStream(dst, src)
return
}
func (m *AES256CFBMethod) Decrypt(dst []byte) (src []byte, err error) {
if len(dst) == 0 {
return
}
defer func() {
err = RecoverMethodPanic(recover())
}()
src = make([]byte, len(dst))
cipher.NewCFBDecrypter(m.block, m.iv).XORKeyStream(src, dst)
return
}

View File

@@ -0,0 +1,15 @@
package encrypt
type RawMethod struct{}
func (m *RawMethod) Init(key []byte, iv []byte) error {
return nil
}
func (m *RawMethod) Encrypt(src []byte) (dst []byte, err error) {
return src, nil
}
func (m *RawMethod) Decrypt(dst []byte) (src []byte, err error) {
return dst, nil
}

View File

@@ -0,0 +1,40 @@
package encrypt
import (
"errors"
"reflect"
)
var methods = map[string]reflect.Type{
"raw": reflect.TypeOf(new(RawMethod)).Elem(),
"aes-256-cfb": reflect.TypeOf(new(AES256CFBMethod)).Elem(),
}
func NewMethodInstance(method string, key string, iv string) (MethodInterface, error) {
valueType, ok := methods[method]
if !ok {
return nil, errors.New("method '" + method + "' not found")
}
instance, ok := reflect.New(valueType).Interface().(MethodInterface)
if !ok {
return nil, errors.New("method '" + method + "' must implement MethodInterface")
}
err := instance.Init([]byte(key), []byte(iv))
return instance, err
}
func RecoverMethodPanic(err interface{}) error {
if err == nil {
return nil
}
if s, ok := err.(string); ok {
return errors.New(s)
}
if e, ok := err.(error); ok {
return e
}
return errors.New("unknown error")
}

View File

@@ -0,0 +1,157 @@
package nodes
import (
"errors"
"log"
"net"
"os"
"os/exec"
"runtime"
"sync"
"time"
teaconst "github.com/TeaOSLab/EdgeHttpDNS/internal/const"
"github.com/TeaOSLab/EdgeHttpDNS/internal/utils"
"github.com/iwind/TeaGo/maps"
"github.com/iwind/gosock/pkg/gosock"
)
type HTTPDNSNode struct {
sock *gosock.Sock
quitOnce sync.Once
quitCh chan struct{}
}
func NewHTTPDNSNode() *HTTPDNSNode {
return &HTTPDNSNode{
sock: gosock.NewTmpSock(teaconst.ProcessName),
quitCh: make(chan struct{}),
}
}
func (n *HTTPDNSNode) Run() {
err := n.listenSock()
if err != nil {
log.Println("[HTTPDNS_NODE]" + err.Error())
return
}
go n.start()
select {}
}
func (n *HTTPDNSNode) Daemon() {
path := os.TempDir() + "/" + teaconst.ProcessName + ".sock"
for {
conn, err := net.DialTimeout("unix", path, 1*time.Second)
if err != nil {
exe, exeErr := os.Executable()
if exeErr != nil {
log.Println("[DAEMON]", exeErr)
time.Sleep(1 * time.Second)
continue
}
cmd := exec.Command(exe)
cmd.Env = append(os.Environ(), "EdgeBackground=on")
if runtime.GOOS != "windows" {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
startErr := cmd.Start()
if startErr != nil {
log.Println("[DAEMON]", startErr)
time.Sleep(1 * time.Second)
continue
}
_ = cmd.Wait()
time.Sleep(5 * time.Second)
continue
}
_ = conn.Close()
time.Sleep(5 * time.Second)
}
}
func (n *HTTPDNSNode) InstallSystemService() error {
exe, err := os.Executable()
if err != nil {
return err
}
manager := utils.NewServiceManager(teaconst.SystemdServiceName, teaconst.ProductName)
return manager.Install(exe, []string{})
}
func (n *HTTPDNSNode) listenSock() error {
if runtime.GOOS == "windows" {
return nil
}
if n.sock.IsListening() {
reply, err := n.sock.Send(&gosock.Command{Code: "pid"})
if err == nil {
return errors.New("the process is already running, pid: " + maps.NewMap(reply.Params).GetString("pid"))
}
return errors.New("the process is already running")
}
go func() {
n.sock.OnCommand(func(cmd *gosock.Command) {
switch cmd.Code {
case "pid":
_ = cmd.Reply(&gosock.Command{
Code: "pid",
Params: map[string]interface{}{
"pid": os.Getpid(),
},
})
case "info":
exePath, _ := os.Executable()
_ = cmd.Reply(&gosock.Command{
Code: "info",
Params: map[string]interface{}{
"pid": os.Getpid(),
"version": teaconst.Version,
"path": exePath,
},
})
case "stop":
_ = cmd.ReplyOk()
n.stop()
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}
})
err := n.sock.Listen()
if err != nil {
log.Println("[HTTPDNS_NODE][sock]", err.Error())
}
}()
return nil
}
func (n *HTTPDNSNode) start() {
log.Println("[HTTPDNS_NODE]started")
snapshotManager := NewSnapshotManager(n.quitCh)
statusManager := NewStatusManager(n.quitCh)
taskManager := NewTaskManager(n.quitCh, snapshotManager)
resolveServer := NewResolveServer(n.quitCh, snapshotManager)
go snapshotManager.Start()
go statusManager.Start()
go taskManager.Start()
go resolveServer.Start()
}
func (n *HTTPDNSNode) stop() {
n.quitOnce.Do(func() {
close(n.quitCh)
_ = n.sock.Close()
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
package nodes
import (
"log"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeHttpDNS/internal/rpc"
)
func reportRuntimeLog(level string, logType string, module string, description string, requestID string) {
rpcClient, err := rpc.SharedRPC()
if err != nil {
log.Println("[HTTPDNS_NODE][runtime-log]", err.Error())
return
}
nodeID := int64(0)
now := time.Now()
_, err = rpcClient.HTTPDNSRuntimeLogRPC.CreateHTTPDNSRuntimeLogs(rpcClient.Context(), &pb.CreateHTTPDNSRuntimeLogsRequest{
Logs: []*pb.HTTPDNSRuntimeLog{
{
NodeId: nodeID,
Level: level,
Type: logType,
Module: module,
Description: description,
Count: 1,
RequestId: requestID,
CreatedAt: now.Unix(),
Day: now.Format("20060102"),
},
},
})
if err != nil {
log.Println("[HTTPDNS_NODE][runtime-log]", err.Error())
}
}

View File

@@ -0,0 +1,160 @@
package nodes
import (
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeHttpDNS/internal/rpc"
)
type LoadedDomain struct {
Domain *pb.HTTPDNSDomain
Rules []*pb.HTTPDNSCustomRule
}
type LoadedApp struct {
App *pb.HTTPDNSApp
Domains map[string]*LoadedDomain // key: lower(domain)
}
type LoadedSnapshot struct {
LoadedAt int64
NodeID int64
ClusterID int64
Clusters map[int64]*pb.HTTPDNSCluster
Apps map[string]*LoadedApp // key: lower(appId)
}
type SnapshotManager struct {
quitCh <-chan struct{}
ticker *time.Ticker
locker sync.RWMutex
snapshot *LoadedSnapshot
}
func NewSnapshotManager(quitCh <-chan struct{}) *SnapshotManager {
return &SnapshotManager{
quitCh: quitCh,
ticker: time.NewTicker(30 * time.Second),
}
}
func (m *SnapshotManager) Start() {
defer m.ticker.Stop()
if err := m.RefreshNow("startup"); err != nil {
log.Println("[HTTPDNS_NODE][snapshot]initial refresh failed:", err.Error())
}
for {
select {
case <-m.ticker.C:
if err := m.RefreshNow("periodic"); err != nil {
log.Println("[HTTPDNS_NODE][snapshot]periodic refresh failed:", err.Error())
}
case <-m.quitCh:
return
}
}
}
func (m *SnapshotManager) Current() *LoadedSnapshot {
m.locker.RLock()
defer m.locker.RUnlock()
return m.snapshot
}
func (m *SnapshotManager) RefreshNow(reason string) error {
rpcClient, err := rpc.SharedRPC()
if err != nil {
return err
}
nodeResp, err := rpcClient.HTTPDNSNodeRPC.FindHTTPDNSNode(rpcClient.Context(), &pb.FindHTTPDNSNodeRequest{
NodeId: 0,
})
if err != nil {
return err
}
if nodeResp.GetNode() == nil {
return fmt.Errorf("httpdns node info not found")
}
clusterResp, err := rpcClient.HTTPDNSClusterRPC.FindAllHTTPDNSClusters(rpcClient.Context(), &pb.FindAllHTTPDNSClustersRequest{})
if err != nil {
return err
}
clusters := map[int64]*pb.HTTPDNSCluster{}
for _, cluster := range clusterResp.GetClusters() {
if cluster == nil || cluster.GetId() <= 0 {
continue
}
clusters[cluster.GetId()] = cluster
}
appResp, err := rpcClient.HTTPDNSAppRPC.FindAllHTTPDNSApps(rpcClient.Context(), &pb.FindAllHTTPDNSAppsRequest{})
if err != nil {
return err
}
apps := map[string]*LoadedApp{}
for _, app := range appResp.GetApps() {
if app == nil || app.GetId() <= 0 || len(app.GetAppId()) == 0 {
continue
}
domainResp, err := rpcClient.HTTPDNSDomainRPC.ListHTTPDNSDomainsWithAppId(rpcClient.Context(), &pb.ListHTTPDNSDomainsWithAppIdRequest{
AppDbId: app.GetId(),
})
if err != nil {
log.Println("[HTTPDNS_NODE][snapshot]list domains failed, appId:", app.GetAppId(), "err:", err.Error())
continue
}
domains := map[string]*LoadedDomain{}
for _, domain := range domainResp.GetDomains() {
if domain == nil || domain.GetId() <= 0 || len(domain.GetDomain()) == 0 {
continue
}
ruleResp, err := rpcClient.HTTPDNSRuleRPC.ListHTTPDNSCustomRulesWithDomainId(rpcClient.Context(), &pb.ListHTTPDNSCustomRulesWithDomainIdRequest{
DomainId: domain.GetId(),
})
if err != nil {
log.Println("[HTTPDNS_NODE][snapshot]list rules failed, domain:", domain.GetDomain(), "err:", err.Error())
continue
}
domains[strings.ToLower(strings.TrimSpace(domain.GetDomain()))] = &LoadedDomain{
Domain: domain,
Rules: ruleResp.GetRules(),
}
}
apps[strings.ToLower(strings.TrimSpace(app.GetAppId()))] = &LoadedApp{
App: app,
Domains: domains,
}
}
snapshot := &LoadedSnapshot{
LoadedAt: time.Now().Unix(),
NodeID: nodeResp.GetNode().GetId(),
ClusterID: nodeResp.GetNode().GetClusterId(),
Clusters: clusters,
Apps: apps,
}
m.locker.Lock()
m.snapshot = snapshot
m.locker.Unlock()
reportRuntimeLog("info", "config", "snapshot", "snapshot refreshed: "+reason, fmt.Sprintf("snapshot-%d", time.Now().UnixNano()))
return nil
}

View File

@@ -0,0 +1,144 @@
package nodes
import (
"encoding/json"
"log"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeHttpDNS/internal/configs"
teaconst "github.com/TeaOSLab/EdgeHttpDNS/internal/const"
"github.com/TeaOSLab/EdgeHttpDNS/internal/rpc"
"github.com/TeaOSLab/EdgeHttpDNS/internal/utils"
)
type StatusManager struct {
quitCh <-chan struct{}
ticker *time.Ticker
}
func NewStatusManager(quitCh <-chan struct{}) *StatusManager {
return &StatusManager{
quitCh: quitCh,
ticker: time.NewTicker(30 * time.Second),
}
}
func (m *StatusManager) Start() {
defer m.ticker.Stop()
m.update()
for {
select {
case <-m.ticker.C:
m.update()
case <-m.quitCh:
return
}
}
}
func (m *StatusManager) update() {
status := m.collectStatus()
statusJSON, err := json.Marshal(status)
if err != nil {
log.Println("[HTTPDNS_NODE][status]marshal status failed:", err.Error())
return
}
rpcClient, err := rpc.SharedRPC()
if err != nil {
log.Println("[HTTPDNS_NODE][status]rpc unavailable:", err.Error())
return
}
config, err := configs.SharedAPIConfig()
if err != nil {
log.Println("[HTTPDNS_NODE][status]load config failed:", err.Error())
return
}
nodeId, _ := strconv.ParseInt(config.NodeId, 10, 64)
_, err = rpcClient.HTTPDNSNodeRPC.UpdateHTTPDNSNodeStatus(rpcClient.Context(), &pb.UpdateHTTPDNSNodeStatusRequest{
NodeId: nodeId,
IsUp: true,
IsInstalled: true,
IsActive: true,
StatusJSON: statusJSON,
})
if err != nil {
log.Println("[HTTPDNS_NODE][status]update status failed:", err.Error())
}
}
func (m *StatusManager) collectStatus() *nodeconfigs.NodeStatus {
now := time.Now().Unix()
status := &nodeconfigs.NodeStatus{
BuildVersion: teaconst.Version,
BuildVersionCode: utils.VersionToLong(teaconst.Version),
ConfigVersion: 0,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
CPULogicalCount: runtime.NumCPU(),
CPUPhysicalCount: runtime.NumCPU(),
IsActive: true,
ConnectionCount: 0,
UpdatedAt: now,
Timestamp: now,
}
rpcClient, err := rpc.SharedRPC()
if err == nil {
total, failed, avgCostSeconds := rpcClient.GetAndResetMetrics()
if total > 0 {
status.APISuccessPercent = float64(total-failed) * 100.0 / float64(total)
status.APIAvgCostSeconds = avgCostSeconds
}
}
hostname, _ := os.Hostname()
status.Hostname = hostname
exePath, _ := os.Executable()
status.ExePath = exePath
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
status.MemoryTotal = memStats.Sys
if status.MemoryTotal > 0 {
status.MemoryUsage = float64(memStats.Alloc) / float64(status.MemoryTotal)
}
load1m, load5m, load15m := readLoadAvg()
status.Load1m = load1m
status.Load5m = load5m
status.Load15m = load15m
return status
}
func readLoadAvg() (float64, float64, float64) {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return 0, 0, 0
}
parts := strings.Fields(strings.TrimSpace(string(data)))
if len(parts) < 3 {
return 0, 0, 0
}
load1m, _ := strconv.ParseFloat(parts[0], 64)
load5m, _ := strconv.ParseFloat(parts[1], 64)
load15m, _ := strconv.ParseFloat(parts[2], 64)
return load1m, load5m, load15m
}

View File

@@ -0,0 +1,131 @@
package nodes
import (
"fmt"
"log"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
teaconst "github.com/TeaOSLab/EdgeHttpDNS/internal/const"
"github.com/TeaOSLab/EdgeHttpDNS/internal/rpc"
)
type TaskManager struct {
quitCh <-chan struct{}
ticker *time.Ticker
version int64
snapshotManager *SnapshotManager
}
func NewTaskManager(quitCh <-chan struct{}, snapshotManager *SnapshotManager) *TaskManager {
return &TaskManager{
quitCh: quitCh,
ticker: time.NewTicker(20 * time.Second),
version: 0,
snapshotManager: snapshotManager,
}
}
func (m *TaskManager) Start() {
defer m.ticker.Stop()
m.processTasks()
for {
select {
case <-m.ticker.C:
m.processTasks()
case <-m.quitCh:
return
}
}
}
func (m *TaskManager) processTasks() {
rpcClient, err := rpc.SharedRPC()
if err != nil {
log.Println("[HTTPDNS_NODE][task]rpc unavailable:", err.Error())
return
}
resp, err := rpcClient.NodeTaskRPC.FindNodeTasks(rpcClient.Context(), &pb.FindNodeTasksRequest{
Version: m.version,
})
if err != nil {
log.Println("[HTTPDNS_NODE][task]fetch tasks failed:", err.Error())
return
}
for _, task := range resp.GetNodeTasks() {
ok, errorMessage := m.handleTask(task)
_, reportErr := rpcClient.NodeTaskRPC.ReportNodeTaskDone(rpcClient.Context(), &pb.ReportNodeTaskDoneRequest{
NodeTaskId: task.GetId(),
IsOk: ok,
Error: errorMessage,
})
if reportErr != nil {
log.Println("[HTTPDNS_NODE][task]report task result failed:", reportErr.Error())
}
if task.GetVersion() > m.version {
m.version = task.GetVersion()
}
}
}
func (m *TaskManager) handleTask(task *pb.NodeTask) (bool, string) {
taskType := task.GetType()
requestID := fmt.Sprintf("task-%d", task.GetId())
switch taskType {
case teaconst.TaskTypeHTTPDNSConfigChanged:
if m.snapshotManager != nil {
if err := m.snapshotManager.RefreshNow(taskType); err != nil {
reportRuntimeLog("error", "config", "task-manager", "refresh snapshot failed: "+err.Error(), requestID)
return false, err.Error()
}
}
reportRuntimeLog("info", "config", "task-manager", "HTTPDNS configuration updated", requestID)
return true, ""
case teaconst.TaskTypeHTTPDNSAppChanged:
if m.snapshotManager != nil {
if err := m.snapshotManager.RefreshNow(taskType); err != nil {
reportRuntimeLog("error", "app", "task-manager", "refresh snapshot failed: "+err.Error(), requestID)
return false, err.Error()
}
}
reportRuntimeLog("info", "app", "task-manager", "HTTPDNS app policy updated", requestID)
return true, ""
case teaconst.TaskTypeHTTPDNSDomainChanged:
if m.snapshotManager != nil {
if err := m.snapshotManager.RefreshNow(taskType); err != nil {
reportRuntimeLog("error", "domain", "task-manager", "refresh snapshot failed: "+err.Error(), requestID)
return false, err.Error()
}
}
reportRuntimeLog("info", "domain", "task-manager", "HTTPDNS domain binding updated", requestID)
return true, ""
case teaconst.TaskTypeHTTPDNSRuleChanged:
if m.snapshotManager != nil {
if err := m.snapshotManager.RefreshNow(taskType); err != nil {
reportRuntimeLog("error", "rule", "task-manager", "refresh snapshot failed: "+err.Error(), requestID)
return false, err.Error()
}
}
reportRuntimeLog("info", "rule", "task-manager", "HTTPDNS custom rule updated", requestID)
return true, ""
case teaconst.TaskTypeHTTPDNSTLSChanged:
if m.snapshotManager != nil {
if err := m.snapshotManager.RefreshNow(taskType); err != nil {
reportRuntimeLog("error", "tls", "task-manager", "refresh snapshot failed: "+err.Error(), requestID)
return false, err.Error()
}
}
reportRuntimeLog("info", "tls", "task-manager", "HTTPDNS TLS config updated", requestID)
return true, ""
default:
reportRuntimeLog("warning", "task", "task-manager", "unknown task type: "+taskType, requestID)
return true, ""
}
}

View File

@@ -0,0 +1,222 @@
package rpc
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"net/url"
"sync"
"time"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeHttpDNS/internal/configs"
teaconst "github.com/TeaOSLab/EdgeHttpDNS/internal/const"
"github.com/TeaOSLab/EdgeHttpDNS/internal/encrypt"
"github.com/iwind/TeaGo/maps"
"github.com/iwind/TeaGo/rands"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/encoding/gzip"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/metadata"
"sync/atomic"
)
type RPCClient struct {
apiConfig *configs.APIConfig
conns []*grpc.ClientConn
locker sync.RWMutex
NodeTaskRPC pb.NodeTaskServiceClient
HTTPDNSNodeRPC pb.HTTPDNSNodeServiceClient
HTTPDNSClusterRPC pb.HTTPDNSClusterServiceClient
HTTPDNSAppRPC pb.HTTPDNSAppServiceClient
HTTPDNSDomainRPC pb.HTTPDNSDomainServiceClient
HTTPDNSRuleRPC pb.HTTPDNSRuleServiceClient
HTTPDNSRuntimeLogRPC pb.HTTPDNSRuntimeLogServiceClient
HTTPDNSAccessLogRPC pb.HTTPDNSAccessLogServiceClient
HTTPDNSSandboxRPC pb.HTTPDNSSandboxServiceClient
totalRequests int64
failedRequests int64
totalCostMs int64
}
func NewRPCClient(apiConfig *configs.APIConfig) (*RPCClient, error) {
if apiConfig == nil {
return nil, errors.New("api config should not be nil")
}
client := &RPCClient{apiConfig: apiConfig}
client.NodeTaskRPC = pb.NewNodeTaskServiceClient(client)
client.HTTPDNSNodeRPC = pb.NewHTTPDNSNodeServiceClient(client)
client.HTTPDNSClusterRPC = pb.NewHTTPDNSClusterServiceClient(client)
client.HTTPDNSAppRPC = pb.NewHTTPDNSAppServiceClient(client)
client.HTTPDNSDomainRPC = pb.NewHTTPDNSDomainServiceClient(client)
client.HTTPDNSRuleRPC = pb.NewHTTPDNSRuleServiceClient(client)
client.HTTPDNSRuntimeLogRPC = pb.NewHTTPDNSRuntimeLogServiceClient(client)
client.HTTPDNSAccessLogRPC = pb.NewHTTPDNSAccessLogServiceClient(client)
client.HTTPDNSSandboxRPC = pb.NewHTTPDNSSandboxServiceClient(client)
err := client.init()
if err != nil {
return nil, err
}
return client, nil
}
func (c *RPCClient) Context() context.Context {
ctx := context.Background()
payload := maps.Map{
"timestamp": time.Now().Unix(),
"type": "httpdns",
"userId": 0,
}
method, err := encrypt.NewMethodInstance(teaconst.EncryptMethod, c.apiConfig.Secret, c.apiConfig.NodeId)
if err != nil {
return context.Background()
}
encrypted, err := method.Encrypt(payload.AsJSON())
if err != nil {
return context.Background()
}
token := base64.StdEncoding.EncodeToString(encrypted)
return metadata.AppendToOutgoingContext(ctx, "nodeId", c.apiConfig.NodeId, "token", token)
}
func (c *RPCClient) UpdateConfig(config *configs.APIConfig) error {
c.apiConfig = config
c.locker.Lock()
defer c.locker.Unlock()
return c.init()
}
func (c *RPCClient) init() error {
conns := []*grpc.ClientConn{}
for _, endpoint := range c.apiConfig.RPCEndpoints {
u, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("parse endpoint failed: %w", err)
}
var conn *grpc.ClientConn
callOptions := grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(128<<20),
grpc.MaxCallSendMsgSize(128<<20),
grpc.UseCompressor(gzip.Name),
)
keepaliveParams := grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
})
if u.Scheme == "http" {
conn, err = grpc.Dial(u.Host, grpc.WithTransportCredentials(insecure.NewCredentials()), callOptions, keepaliveParams)
} else if u.Scheme == "https" {
conn, err = grpc.Dial(u.Host, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
InsecureSkipVerify: true,
})), callOptions, keepaliveParams)
} else {
return errors.New("invalid endpoint scheme '" + u.Scheme + "'")
}
if err != nil {
return err
}
conns = append(conns, conn)
}
if len(conns) == 0 {
return errors.New("no available rpc endpoints")
}
c.conns = conns
return nil
}
func (c *RPCClient) pickConn() *grpc.ClientConn {
c.locker.RLock()
defer c.locker.RUnlock()
countConns := len(c.conns)
if countConns == 0 {
return nil
}
if countConns == 1 {
return c.conns[0]
}
for _, state := range []connectivity.State{
connectivity.Ready,
connectivity.Idle,
connectivity.Connecting,
connectivity.TransientFailure,
} {
available := []*grpc.ClientConn{}
for _, conn := range c.conns {
if conn.GetState() == state {
available = append(available, conn)
}
}
if len(available) > 0 {
return c.randConn(available)
}
}
return c.randConn(c.conns)
}
func (c *RPCClient) randConn(conns []*grpc.ClientConn) *grpc.ClientConn {
l := len(conns)
if l == 0 {
return nil
}
if l == 1 {
return conns[0]
}
return conns[rands.Int(0, l-1)]
}
func (c *RPCClient) Invoke(ctx context.Context, method string, args interface{}, reply interface{}, opts ...grpc.CallOption) error {
conn := c.pickConn()
if conn == nil {
return errors.New("can not get available grpc connection")
}
atomic.AddInt64(&c.totalRequests, 1)
start := time.Now()
err := conn.Invoke(ctx, method, args, reply, opts...)
costMs := time.Since(start).Milliseconds()
atomic.AddInt64(&c.totalCostMs, costMs)
if err != nil {
atomic.AddInt64(&c.failedRequests, 1)
}
return err
}
func (c *RPCClient) GetAndResetMetrics() (total int64, failed int64, avgCostSeconds float64) {
total = atomic.SwapInt64(&c.totalRequests, 0)
failed = atomic.SwapInt64(&c.failedRequests, 0)
costMs := atomic.SwapInt64(&c.totalCostMs, 0)
if total > 0 {
avgCostSeconds = float64(costMs) / float64(total) / 1000.0
}
return
}
func (c *RPCClient) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
conn := c.pickConn()
if conn == nil {
return nil, errors.New("can not get available grpc connection")
}
return conn.NewStream(ctx, desc, method, opts...)
}

View File

@@ -0,0 +1,31 @@
package rpc
import (
"sync"
"github.com/TeaOSLab/EdgeHttpDNS/internal/configs"
)
var sharedRPCClient *RPCClient
var sharedLocker sync.Mutex
func SharedRPC() (*RPCClient, error) {
sharedLocker.Lock()
defer sharedLocker.Unlock()
config, err := configs.SharedAPIConfig()
if err != nil {
return nil, err
}
if sharedRPCClient == nil {
client, err := NewRPCClient(config)
if err != nil {
return nil, err
}
sharedRPCClient = client
return sharedRPCClient, nil
}
return sharedRPCClient, nil
}

View File

@@ -0,0 +1,18 @@
package utils
import (
"encoding/binary"
"net"
)
func IP2Long(ip string) uint32 {
s := net.ParseIP(ip)
if s == nil {
return 0
}
if len(s) == 16 {
return binary.BigEndian.Uint32(s[12:16])
}
return binary.BigEndian.Uint32(s)
}

View File

@@ -0,0 +1,46 @@
package utils
import (
"os"
"path/filepath"
"sync"
"github.com/iwind/TeaGo/Tea"
)
type ServiceManager struct {
Name string
Description string
onceLocker sync.Once
}
func NewServiceManager(name, description string) *ServiceManager {
manager := &ServiceManager{
Name: name,
Description: description,
}
manager.resetRoot()
return manager
}
func (m *ServiceManager) setup() {
m.onceLocker.Do(func() {})
}
func (m *ServiceManager) resetRoot() {
if !Tea.IsTesting() {
exePath, err := os.Executable()
if err != nil {
exePath = os.Args[0]
}
link, err := filepath.EvalSymlinks(exePath)
if err == nil {
exePath = link
}
fullPath, err := filepath.Abs(exePath)
if err == nil {
Tea.UpdateRoot(filepath.Dir(filepath.Dir(fullPath)))
}
}
}

View File

@@ -0,0 +1,65 @@
//go:build linux
// +build linux
package utils
import (
"errors"
"os"
"os/exec"
teaconst "github.com/TeaOSLab/EdgeHttpDNS/internal/const"
)
var systemdServiceFile = "/etc/systemd/system/" + teaconst.SystemdServiceName + ".service"
func (m *ServiceManager) Install(exePath string, args []string) error {
if os.Getgid() != 0 {
return errors.New("only root users can install the service")
}
systemd, err := exec.LookPath("systemctl")
if err != nil {
return err
}
desc := `[Unit]
Description=GoEdge HTTPDNS Node Service
Before=shutdown.target
After=network-online.target
[Service]
Type=simple
Restart=always
RestartSec=1s
ExecStart=` + exePath + ` daemon
ExecStop=` + exePath + ` stop
ExecReload=` + exePath + ` restart
[Install]
WantedBy=multi-user.target`
err = os.WriteFile(systemdServiceFile, []byte(desc), 0777)
if err != nil {
return err
}
_ = exec.Command(systemd, "stop", teaconst.SystemdServiceName+".service").Run()
_ = exec.Command(systemd, "daemon-reload").Run()
return exec.Command(systemd, "enable", teaconst.SystemdServiceName+".service").Run()
}
func (m *ServiceManager) Uninstall() error {
if os.Getgid() != 0 {
return errors.New("only root users can uninstall the service")
}
systemd, err := exec.LookPath("systemctl")
if err != nil {
return err
}
_ = exec.Command(systemd, "disable", teaconst.SystemdServiceName+".service").Run()
_ = exec.Command(systemd, "daemon-reload").Run()
return os.Remove(systemdServiceFile)
}

View File

@@ -0,0 +1,14 @@
//go:build !linux
// +build !linux
package utils
import "errors"
func (m *ServiceManager) Install(exePath string, args []string) error {
return errors.New("service install is only supported on linux in this version")
}
func (m *ServiceManager) Uninstall() error {
return errors.New("service uninstall is only supported on linux in this version")
}

View File

@@ -0,0 +1,15 @@
package utils
import "strings"
func VersionToLong(version string) uint32 {
countDots := strings.Count(version, ".")
if countDots == 2 {
version += ".0"
} else if countDots == 1 {
version += ".0.0"
} else if countDots == 0 {
version += ".0.0.0"
}
return IP2Long(version)
}

View File

@@ -1,148 +1,77 @@
# Alicloud HTTPDNS Android SDK
# HTTPDNS Android SDK (SNI Hidden v1.0.0)
面向 Android 的 HTTP/HTTPS DNS 解析 SDK提供鉴权与可选 AES 加密、IPv4/IPv6 双栈解析、缓存与调度、预解析等能力。最低支持 Android API 19Android 4.4)。
## 功能特性
- 鉴权请求与可选 AES 传输加密
- IPv4/IPv6 双栈解析,支持自动/同时解析
- 内存 + 持久化缓存与 TTL 控制,可选择复用过期 IP
- 预解析、区域路由、网络切换自动刷新
- 可定制日志回调与会话追踪 `sessionId`
## 安装Gradle
在项目的 `build.gradle` 中添加:
```groovy
dependencies {
implementation 'com.aliyun.ams:alicloud-android-httpdns:2.6.7'
}
```
请访问 [Android SDK发布说明](https://help.aliyun.com/document_detail/435251.html) 查看最新版本号。
## 快速开始
### Java
## 1. Init
```java
import com.alibaba.sdk.android.httpdns.HttpDns;
import com.alibaba.sdk.android.httpdns.HttpDnsService;
import com.alibaba.sdk.android.httpdns.InitConfig;
import com.alibaba.sdk.android.httpdns.RequestIpType;
// 初始化配置
String appId = "app1f1ndpo9";
new InitConfig.Builder()
.setContext(context)
.setSecretKey("YOUR_SECRET_KEY")
.setEnableExpiredIp(true) // 允许返回过期 IP
.buildFor("YOUR_ACCOUNT_ID");
.setPrimaryServiceHost("httpdns-a.example.com")
.setBackupServiceHost("httpdns-b.example.com")
.setServicePort(443)
.setSecretKey("your-sign-secret") // optional if sign is enabled
.setEnableHttps(true)
.buildFor(appId);
// 获取实例
HttpDnsService httpDns = HttpDns.getService("YOUR_ACCOUNT_ID");
// 预解析热点域名
httpDns.setPreResolveHosts(new ArrayList<>(Arrays.asList("www.aliyun.com")));
// 解析域名
HTTPDNSResult result = httpDns.getHttpDnsResultForHostSyncNonBlocking("www.aliyun.com", RequestIpType.auto);
String[] ips = result.getIps();
HttpDnsService httpDnsService = HttpDns.getService(appId);
```
### Kotlin
```kotlin
import com.alibaba.sdk.android.httpdns.HttpDns
import com.alibaba.sdk.android.httpdns.InitConfig
import com.alibaba.sdk.android.httpdns.RequestIpType
// 初始化配置
InitConfig.Builder()
.setContext(context)
.setSecretKey("YOUR_SECRET_KEY")
.setEnableExpiredIp(true) // 允许返回过期 IP
.buildFor("YOUR_ACCOUNT_ID")
// 获取实例
val httpDns = HttpDns.getService("YOUR_ACCOUNT_ID")
// 预解析热点域名
httpDns.setPreResolveHosts(arrayListOf("www.aliyun.com"))
// 解析域名
val result = httpDns.getHttpDnsResultForHostSyncNonBlocking("www.aliyun.com", RequestIpType.auto)
val ips = result.ips
```
### 提示
- 启动时通过 `setPreResolveHosts()` 预热热点域名
- 如需在刷新期间容忍 TTL 过期,可开启 `setEnableExpiredIp(true)`
- 使用 `getSessionId()` 并与选用 IP 一同记录,便于排障
## 源码构建
```bash
./gradlew clean :httpdns-sdk:assembleRelease
```
构建产物位于 `httpdns-sdk/build/outputs/aar/` 目录。
### 版本说明
项目使用 `productFlavors` 区分不同版本:
- `normal`:中国大陆版本
- `intl`:国际版本
- `end2end`:用于单元测试
## 测试
### 运行单元测试
```bash
./gradlew clean :httpdns-sdk:testEnd2endForTestUnitTest
```
### Demo 应用
SDK 提供了两个 Demo
#### 1. app module旧版 Demo
`MyApp.java` 中配置测试账号:
## 2. Resolve
```java
private HttpDnsHolder holderA = new HttpDnsHolder("请替换为测试用A实例的accountId", "请替换为测试用A实例的secret");
private HttpDnsHolder holderB = new HttpDnsHolder("请替换为测试用B实例的accountId", null);
HTTPDNSResult result = httpDnsService.getHttpDnsResultForHostSyncNonBlocking(
"api.business.com",
RequestIpType.auto,
null,
null
);
```
> 两个实例用于测试实例间互不影响,体验时只需配置一个
## 3. Official HTTP Adapter (IP + Empty-SNI + Host)
#### 2. demo module推荐
```java
import com.alibaba.sdk.android.httpdns.network.HttpDnsAdapterOptions;
import com.alibaba.sdk.android.httpdns.network.HttpDnsAdapterRequest;
import com.alibaba.sdk.android.httpdns.network.HttpDnsAdapterResponse;
import com.alibaba.sdk.android.httpdns.network.HttpDnsHttpAdapter;
使用 Kotlin + MVVM 开发,功能更丰富。在 `demo/build.gradle` 中配置测试账号:
HttpDnsHttpAdapter adapter = HttpDns.buildHttpClientAdapter(
httpDnsService,
new HttpDnsAdapterOptions.Builder()
.setConnectTimeoutMillis(3000)
.setReadTimeoutMillis(5000)
.setRequestIpType(RequestIpType.auto)
.setAllowInsecureCertificatesForDebugOnly(false)
.build()
);
```groovy
buildConfigField "String", "ACCOUNT_ID", "\"请替换为测试用实例的accountId\""
buildConfigField "String", "SECRET_KEY", "\"请替换为测试用实例的secret\""
buildConfigField "String", "AES_SECRET_KEY", "\"请替换为测试用实例的aes\""
HttpDnsAdapterResponse response = adapter.execute(
new HttpDnsAdapterRequest("GET", "https://api.business.com/v1/ping")
);
```
## 依赖与要求
Behavior is fixed:
- Resolve by `/resolve`.
- Connect to resolved IP over HTTPS.
- Keep `Host` header as business domain.
- No fallback to domain direct request.
- Android API 19+Android 4.4+
- 需要权限:`INTERNET``ACCESS_NETWORK_STATE`
## 4. Public Errors
## 安全说明
- `NO_IP_AVAILABLE`
- `TLS_EMPTY_SNI_FAILED`
- `HOST_ROUTE_REJECTED`
- `RESOLVE_SIGN_INVALID`
- 切勿提交真实的 AccountID/SecretKey请通过本地安全配置或 CI 注入
- 若担心设备时间偏差影响鉴权,可使用 `setAuthCurrentTime()` 校正时间
## 5. Removed Public Params
## 文档
官方文档:[Android SDK手册](https://help.aliyun.com/document_detail/435250.html)
## 感谢
本项目中 Inet64Util 工具类由 [Shinelw](https://github.com/Shinelw) 贡献支持,感谢。
Do not use legacy public parameters:
- `accountId`
- `serviceDomain`
- `endpoint`
- `aesSecretKey`

View File

@@ -5,6 +5,8 @@ import android.content.Context;
import com.alibaba.sdk.android.httpdns.impl.HttpDnsInstanceHolder;
import com.alibaba.sdk.android.httpdns.impl.InstanceCreator;
import com.alibaba.sdk.android.httpdns.net.NetworkStateManager;
import com.alibaba.sdk.android.httpdns.network.HttpDnsAdapterOptions;
import com.alibaba.sdk.android.httpdns.network.HttpDnsHttpAdapter;
import com.alibaba.sdk.android.httpdns.utils.CommonUtil;
/**
@@ -66,4 +68,18 @@ public class HttpDns {
sHolder = new HttpDnsInstanceHolder(new InstanceCreator());
NetworkStateManager.getInstance().reset();
}
/**
* Build official IP-direct + empty-SNI adapter.
*/
public static HttpDnsHttpAdapter buildHttpClientAdapter(HttpDnsService service) {
return new HttpDnsHttpAdapter(service);
}
/**
* Build official IP-direct + empty-SNI adapter with options.
*/
public static HttpDnsHttpAdapter buildHttpClientAdapter(HttpDnsService service, HttpDnsAdapterOptions options) {
return new HttpDnsHttpAdapter(service, options);
}
}

View File

@@ -2,6 +2,8 @@ package com.alibaba.sdk.android.httpdns;
import com.alibaba.sdk.android.httpdns.impl.HttpDnsInstanceHolder;
import com.alibaba.sdk.android.httpdns.impl.InstanceCreator;
import com.alibaba.sdk.android.httpdns.network.HttpDnsAdapterOptions;
import com.alibaba.sdk.android.httpdns.network.HttpDnsHttpAdapter;
import com.alibaba.sdk.android.httpdns.utils.CommonUtil;
import android.content.Context;
@@ -73,6 +75,20 @@ public class HttpDns {
InitConfig.addConfig(accountId, config);
}
/**
* Build official IP-direct + empty-SNI adapter.
*/
public static HttpDnsHttpAdapter buildHttpClientAdapter(HttpDnsService service) {
return new HttpDnsHttpAdapter(service);
}
/**
* Build official IP-direct + empty-SNI adapter with options.
*/
public static HttpDnsHttpAdapter buildHttpClientAdapter(HttpDnsService service, HttpDnsAdapterOptions options) {
return new HttpDnsHttpAdapter(service, options);
}
/**
* 启用或者禁用httpdns理论上这个是内部接口不给外部使用的
* 但是已经对外暴露,所以保留

View File

@@ -0,0 +1,71 @@
package com.alibaba.sdk.android.httpdns;
import android.content.Context;
import android.text.TextUtils;
import com.alibaba.sdk.android.httpdns.network.HttpDnsAdapterOptions;
import com.alibaba.sdk.android.httpdns.network.HttpDnsHttpAdapter;
import java.util.HashMap;
import java.util.Map;
public final class HttpDnsV1Client {
private HttpDnsV1Client() {
}
public static HttpDnsService init(Context context,
String appId,
String primaryServiceHost,
String backupServiceHost,
int servicePort,
String signSecret) {
InitConfig.Builder builder = new InitConfig.Builder()
.setContext(context)
.setEnableHttps(true)
.setPrimaryServiceHost(primaryServiceHost)
.setServicePort(servicePort > 0 ? servicePort : 443);
if (!TextUtils.isEmpty(backupServiceHost)) {
builder.setBackupServiceHost(backupServiceHost);
}
if (!TextUtils.isEmpty(signSecret)) {
builder.setSecretKey(signSecret);
}
builder.buildFor(appId);
if (!TextUtils.isEmpty(signSecret)) {
return HttpDns.getService(context, appId, signSecret);
}
return HttpDns.getService(context, appId);
}
public static HTTPDNSResult resolveHost(HttpDnsService service,
String host,
String qtype,
String cip) {
if (service == null) {
return HTTPDNSResult.empty(host);
}
RequestIpType requestIpType = RequestIpType.auto;
if ("AAAA".equalsIgnoreCase(qtype)) {
requestIpType = RequestIpType.v6;
}
Map<String, String> params = null;
if (!TextUtils.isEmpty(cip)) {
params = new HashMap<>();
params.put("cip", cip);
}
return service.getHttpDnsResultForHostSyncNonBlocking(host, requestIpType, params, host);
}
public static HttpDnsHttpAdapter buildHttpClientAdapter(HttpDnsService service) {
return HttpDns.buildHttpClientAdapter(service);
}
public static HttpDnsHttpAdapter buildHttpClientAdapter(HttpDnsService service,
HttpDnsAdapterOptions options) {
return HttpDns.buildHttpClientAdapter(service, options);
}
}

View File

@@ -61,6 +61,9 @@ public class InitConfig {
private final String mBizTags;
private final String aesSecretKey;
private final String secretKey;
private final String primaryServiceHost;
private final String backupServiceHost;
private final int servicePort;
private final Context context;
private InitConfig(Builder builder) {
@@ -82,6 +85,9 @@ public class InitConfig {
mEnableObservable = builder.enableObservable;
mBizTags = builder.bizTags;
aesSecretKey = builder.aesSecretKey;
primaryServiceHost = builder.primaryServiceHost;
backupServiceHost = builder.backupServiceHost;
servicePort = builder.servicePort;
context = builder.context;
secretKey = builder.secretKey;
}
@@ -159,6 +165,18 @@ public class InitConfig {
return aesSecretKey;
}
public String getPrimaryServiceHost() {
return primaryServiceHost;
}
public String getBackupServiceHost() {
return backupServiceHost;
}
public int getServicePort() {
return servicePort;
}
public Context getContext() {return context;}
public String getSecretKey() {
@@ -184,6 +202,9 @@ public class InitConfig {
private Map<String, String> sdnsGlobalParams = null;
private String bizTags = null;
private String aesSecretKey = null;
private String primaryServiceHost = null;
private String backupServiceHost = null;
private int servicePort = -1;
private Context context = null;
private String secretKey = null;
@@ -415,6 +436,43 @@ public class InitConfig {
return this;
}
/**
* 设置主服务域名。
*/
public Builder setPrimaryServiceHost(String host) {
this.primaryServiceHost = host;
return this;
}
/**
* 设置备服务域名。
*/
public Builder setBackupServiceHost(String host) {
this.backupServiceHost = host;
return this;
}
/**
* 批量设置服务域名,支持主备两个。
*/
public Builder setServiceHosts(List<String> hosts) {
if (hosts != null && hosts.size() > 0) {
this.primaryServiceHost = hosts.get(0);
}
if (hosts != null && hosts.size() > 1) {
this.backupServiceHost = hosts.get(1);
}
return this;
}
/**
* 设置服务端口,默认 -1 表示使用协议默认端口。
*/
public Builder setServicePort(int port) {
this.servicePort = port;
return this;
}
/**
* 设置context
* @param context 上下文

View File

@@ -1,6 +1,7 @@
package com.alibaba.sdk.android.httpdns.impl;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -41,6 +42,7 @@ import com.alibaba.sdk.android.httpdns.utils.Constants;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Looper;
import android.text.TextUtils;
/**
* 域名解析服务 httpdns接口的实现
@@ -60,6 +62,7 @@ public class HttpDnsServiceImpl implements HttpDnsService, OnRegionServerIpUpdat
private SignService mSignService;
private AESEncryptService mAESEncryptService;
private boolean resolveAfterNetworkChange = true;
private boolean mUseCustomServiceHosts = false;
/**
* crash defend 默认关闭
*/
@@ -115,7 +118,9 @@ public class HttpDnsServiceImpl implements HttpDnsService, OnRegionServerIpUpdat
tryUpdateRegionServer(sContext, accountId);
mRegionServerRankingService.rankServiceIp(mHttpDnsConfig.getCurrentServer());
if (!mUseCustomServiceHosts) {
mRegionServerRankingService.rankServiceIp(mHttpDnsConfig.getCurrentServer());
}
favorInit(sContext, accountId);
if (HttpDnsLog.isPrint()) {
@@ -163,10 +168,37 @@ public class HttpDnsServiceImpl implements HttpDnsService, OnRegionServerIpUpdat
mRequestHandler.setSdnsGlobalParams(config.getSdnsGlobalParams());
mAESEncryptService.setAesSecretKey(config.getAesSecretKey());
applyCustomServiceHosts(config);
}
}
private void applyCustomServiceHosts(InitConfig config) {
String primaryHost = config.getPrimaryServiceHost();
String backupHost = config.getBackupServiceHost();
ArrayList<String> hosts = new ArrayList<>();
if (!TextUtils.isEmpty(primaryHost)) {
hosts.add(primaryHost.trim());
}
if (!TextUtils.isEmpty(backupHost) && !backupHost.trim().equalsIgnoreCase(primaryHost == null ? "" : primaryHost.trim())) {
hosts.add(backupHost.trim());
}
if (hosts.isEmpty()) {
return;
}
String[] serverHosts = hosts.toArray(new String[0]);
int[] ports = new int[serverHosts.length];
int servicePort = config.getServicePort();
Arrays.fill(ports, servicePort > 0 ? servicePort : -1);
mHttpDnsConfig.setInitServers(mHttpDnsConfig.getRegion(), serverHosts, ports, serverHosts, ports);
mHttpDnsConfig.setDefaultUpdateServer(serverHosts, ports);
mHttpDnsConfig.setDefaultUpdateServerIpv6(serverHosts, ports);
mHttpDnsConfig.setHTTPSRequestEnabled(true);
mUseCustomServiceHosts = true;
}
protected void beforeInit() {
// only for test
}
@@ -218,7 +250,9 @@ public class HttpDnsServiceImpl implements HttpDnsService, OnRegionServerIpUpdat
mRequestHandler.resetStatus();
//服务IP更新触发服务IP测速
mRegionServerRankingService.rankServiceIp(mHttpDnsConfig.getCurrentServer());
if (!mUseCustomServiceHosts) {
mRegionServerRankingService.rankServiceIp(mHttpDnsConfig.getCurrentServer());
}
}
@Override
@@ -589,6 +623,12 @@ public class HttpDnsServiceImpl implements HttpDnsService, OnRegionServerIpUpdat
@Override
public void setRegion(String region) {
if (mUseCustomServiceHosts) {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.d("ignore setRegion in custom service host mode");
}
return;
}
if (!mHttpDnsConfig.isEnabled()) {
HttpDnsLog.i("service is disabled");
return;
@@ -605,9 +645,13 @@ public class HttpDnsServiceImpl implements HttpDnsService, OnRegionServerIpUpdat
if (changed) {
mResultRepo.clearMemoryCache();
//region变化服务IP变成对应的预置IP触发测速
mRegionServerRankingService.rankServiceIp(mHttpDnsConfig.getCurrentServer());
if (!mUseCustomServiceHosts) {
mRegionServerRankingService.rankServiceIp(mHttpDnsConfig.getCurrentServer());
}
}
if (!mUseCustomServiceHosts) {
mScheduleService.updateRegionServerIps(region, Constants.UPDATE_REGION_SERVER_SCENES_REGION_CHANGE);
}
mScheduleService.updateRegionServerIps(region, Constants.UPDATE_REGION_SERVER_SCENES_REGION_CHANGE);
}
@Override
@@ -684,7 +728,9 @@ public class HttpDnsServiceImpl implements HttpDnsService, OnRegionServerIpUpdat
});
//网络变化触发服务IP测速
mRegionServerRankingService.rankServiceIp(mHttpDnsConfig.getCurrentServer());
if (!mUseCustomServiceHosts) {
mRegionServerRankingService.rankServiceIp(mHttpDnsConfig.getCurrentServer());
}
} catch (Exception e) {
}
}
@@ -817,6 +863,9 @@ public class HttpDnsServiceImpl implements HttpDnsService, OnRegionServerIpUpdat
}
private void tryUpdateRegionServer(Context context, String accountId) {
if (mUseCustomServiceHosts) {
return;
}
if (mHttpDnsConfig.getCurrentServer().shouldUpdateServerIp()) {
mScheduleService.updateRegionServerIps(Constants.UPDATE_REGION_SERVER_SCENES_INIT);

View File

@@ -3,7 +3,6 @@ package com.alibaba.sdk.android.httpdns.impl;
import android.text.TextUtils;
import com.alibaba.sdk.android.httpdns.log.HttpDnsLog;
import com.alibaba.sdk.android.httpdns.utils.CommonUtil;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
@@ -67,6 +66,30 @@ public class SignService {
return signStr;
}
/**
* 新版 /resolve 请求签名:
* appId|lower(domain)|upper(qtype)|exp|nonce
*/
public String signResolve(String appId, String domain, String qtype, String exp, String nonce) {
if (TextUtils.isEmpty(mSecretKey)
|| TextUtils.isEmpty(appId)
|| TextUtils.isEmpty(domain)
|| TextUtils.isEmpty(qtype)
|| TextUtils.isEmpty(exp)
|| TextUtils.isEmpty(nonce)) {
return "";
}
String raw = appId + "|" + domain.toLowerCase() + "|" + qtype.toUpperCase() + "|" + exp + "|" + nonce;
try {
return hmacSha256(raw);
} catch (Exception e) {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.e("sign resolve fail.", e);
}
return "";
}
}
private static String generateV2SignContent(Map<String, String> map) {
Map<String, String> sortedMap = new TreeMap<>();
for(Map.Entry<String, String> entry : map.entrySet()) {
@@ -91,9 +114,17 @@ public class SignService {
private String hmacSha256(String content)
throws NoSuchAlgorithmException, InvalidKeyException {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(CommonUtil.decodeHex(mSecretKey), ALGORITHM));
mac.init(new SecretKeySpec(mSecretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM));
byte[] signedBytes = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
return CommonUtil.encodeHexString(signedBytes);
StringBuilder sb = new StringBuilder(signedBytes.length * 2);
for (byte b : signedBytes) {
String h = Integer.toHexString(b & 0xff);
if (h.length() == 1) {
sb.append('0');
}
sb.append(h);
}
return sb.toString();
}
public void setCurrentTimestamp(long serverTime) {

View File

@@ -0,0 +1,21 @@
package com.alibaba.sdk.android.httpdns.network;
import java.io.IOException;
public class HttpDnsAdapterException extends IOException {
private final String errorCode;
public HttpDnsAdapterException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public HttpDnsAdapterException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,70 @@
package com.alibaba.sdk.android.httpdns.network;
import com.alibaba.sdk.android.httpdns.RequestIpType;
public class HttpDnsAdapterOptions {
private final int connectTimeoutMillis;
private final int readTimeoutMillis;
private final boolean allowInsecureCertificatesForDebugOnly;
private final RequestIpType requestIpType;
private HttpDnsAdapterOptions(Builder builder) {
connectTimeoutMillis = builder.connectTimeoutMillis;
readTimeoutMillis = builder.readTimeoutMillis;
allowInsecureCertificatesForDebugOnly = builder.allowInsecureCertificatesForDebugOnly;
requestIpType = builder.requestIpType;
}
public int getConnectTimeoutMillis() {
return connectTimeoutMillis;
}
public int getReadTimeoutMillis() {
return readTimeoutMillis;
}
public boolean isAllowInsecureCertificatesForDebugOnly() {
return allowInsecureCertificatesForDebugOnly;
}
public RequestIpType getRequestIpType() {
return requestIpType;
}
public static class Builder {
private int connectTimeoutMillis = 3000;
private int readTimeoutMillis = 5000;
private boolean allowInsecureCertificatesForDebugOnly = false;
private RequestIpType requestIpType = RequestIpType.auto;
public Builder setConnectTimeoutMillis(int value) {
if (value > 0) {
connectTimeoutMillis = value;
}
return this;
}
public Builder setReadTimeoutMillis(int value) {
if (value > 0) {
readTimeoutMillis = value;
}
return this;
}
public Builder setAllowInsecureCertificatesForDebugOnly(boolean value) {
allowInsecureCertificatesForDebugOnly = value;
return this;
}
public Builder setRequestIpType(RequestIpType type) {
if (type != null) {
requestIpType = type;
}
return this;
}
public HttpDnsAdapterOptions build() {
return new HttpDnsAdapterOptions(this);
}
}
}

View File

@@ -0,0 +1,43 @@
package com.alibaba.sdk.android.httpdns.network;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public class HttpDnsAdapterRequest {
private final String method;
private final String url;
private final Map<String, String> headers;
private final byte[] body;
public HttpDnsAdapterRequest(String method, String url) {
this(method, url, null, null);
}
public HttpDnsAdapterRequest(String method, String url, Map<String, String> headers, byte[] body) {
this.method = method == null || method.trim().isEmpty() ? "GET" : method.trim().toUpperCase();
this.url = url;
if (headers == null || headers.isEmpty()) {
this.headers = Collections.emptyMap();
} else {
this.headers = Collections.unmodifiableMap(new LinkedHashMap<>(headers));
}
this.body = body;
}
public String getMethod() {
return method;
}
public String getUrl() {
return url;
}
public Map<String, String> getHeaders() {
return headers;
}
public byte[] getBody() {
return body;
}
}

View File

@@ -0,0 +1,34 @@
package com.alibaba.sdk.android.httpdns.network;
import java.util.List;
import java.util.Map;
public class HttpDnsAdapterResponse {
private final int statusCode;
private final Map<String, List<String>> headers;
private final byte[] body;
private final String usedIp;
public HttpDnsAdapterResponse(int statusCode, Map<String, List<String>> headers, byte[] body, String usedIp) {
this.statusCode = statusCode;
this.headers = headers;
this.body = body;
this.usedIp = usedIp;
}
public int getStatusCode() {
return statusCode;
}
public Map<String, List<String>> getHeaders() {
return headers;
}
public byte[] getBody() {
return body;
}
public String getUsedIp() {
return usedIp;
}
}

View File

@@ -0,0 +1,11 @@
package com.alibaba.sdk.android.httpdns.network;
public final class HttpDnsErrorCode {
private HttpDnsErrorCode() {
}
public static final String NO_IP_AVAILABLE = "NO_IP_AVAILABLE";
public static final String TLS_EMPTY_SNI_FAILED = "TLS_EMPTY_SNI_FAILED";
public static final String HOST_ROUTE_REJECTED = "HOST_ROUTE_REJECTED";
public static final String RESOLVE_SIGN_INVALID = "RESOLVE_SIGN_INVALID";
}

View File

@@ -0,0 +1,308 @@
package com.alibaba.sdk.android.httpdns.network;
import com.alibaba.sdk.android.httpdns.HTTPDNSResult;
import com.alibaba.sdk.android.httpdns.HttpDnsService;
import com.alibaba.sdk.android.httpdns.RequestIpType;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
public class HttpDnsHttpAdapter {
private static final int BUFFER_SIZE = 4096;
private final HttpDnsService httpDnsService;
private final HttpDnsAdapterOptions options;
public HttpDnsHttpAdapter(HttpDnsService httpDnsService) {
this(httpDnsService, new HttpDnsAdapterOptions.Builder().build());
}
public HttpDnsHttpAdapter(HttpDnsService httpDnsService, HttpDnsAdapterOptions options) {
if (httpDnsService == null) {
throw new IllegalArgumentException("httpDnsService should not be null");
}
this.httpDnsService = httpDnsService;
this.options = options == null ? new HttpDnsAdapterOptions.Builder().build() : options;
}
public HttpDnsAdapterResponse execute(HttpDnsAdapterRequest request) throws IOException {
if (request == null || request.getUrl() == null || request.getUrl().trim().isEmpty()) {
throw new HttpDnsAdapterException(HttpDnsErrorCode.HOST_ROUTE_REJECTED,
"request or url is empty");
}
URL originalURL = new URL(request.getUrl());
String originalHost = originalURL.getHost();
if (originalHost == null || originalHost.trim().isEmpty()) {
throw new HttpDnsAdapterException(HttpDnsErrorCode.HOST_ROUTE_REJECTED,
"invalid original host");
}
if (!"https".equalsIgnoreCase(originalURL.getProtocol())) {
throw new HttpDnsAdapterException(HttpDnsErrorCode.TLS_EMPTY_SNI_FAILED,
"only https scheme is supported in empty sni mode");
}
List<String> candidateIps = resolveIps(originalHost);
if (candidateIps.isEmpty()) {
throw new HttpDnsAdapterException(HttpDnsErrorCode.NO_IP_AVAILABLE,
"HTTPDNS returned no ip for host: " + originalHost);
}
IOException lastException = null;
for (String ip : candidateIps) {
if (ip == null || ip.trim().isEmpty()) {
continue;
}
String usedIp = ip.trim();
HttpsURLConnection connection = null;
try {
int port = originalURL.getPort() > 0 ? originalURL.getPort() : 443;
URL targetURL = new URL("https", usedIp, port, originalURL.getFile());
connection = (HttpsURLConnection) targetURL.openConnection();
connection.setConnectTimeout(options.getConnectTimeoutMillis());
connection.setReadTimeout(options.getReadTimeoutMillis());
connection.setInstanceFollowRedirects(false);
connection.setRequestMethod(request.getMethod());
connection.setSSLSocketFactory(createSocketFactory());
connection.setHostnameVerifier(createHostnameVerifier(originalHost));
connection.setRequestProperty("Host", originalHost);
for (Map.Entry<String, String> entry : request.getHeaders().entrySet()) {
if (entry.getKey() == null || entry.getValue() == null) {
continue;
}
if ("host".equalsIgnoreCase(entry.getKey())) {
continue;
}
connection.setRequestProperty(entry.getKey(), entry.getValue());
}
byte[] body = request.getBody();
if (body != null && body.length > 0 && allowsRequestBody(request.getMethod())) {
connection.setDoOutput(true);
connection.getOutputStream().write(body);
}
int code = connection.getResponseCode();
byte[] payload = readResponseBytes(connection, code);
Map<String, List<String>> headers = sanitizeHeaders(connection.getHeaderFields());
return new HttpDnsAdapterResponse(code, headers, payload, usedIp);
} catch (IOException e) {
lastException = e;
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
if (lastException == null) {
throw new HttpDnsAdapterException(HttpDnsErrorCode.TLS_EMPTY_SNI_FAILED,
"all ip attempts failed with no explicit exception");
}
throw new HttpDnsAdapterException(HttpDnsErrorCode.TLS_EMPTY_SNI_FAILED,
"all ip attempts failed", lastException);
}
private List<String> resolveIps(String host) {
RequestIpType type = options.getRequestIpType();
HTTPDNSResult result = httpDnsService.getHttpDnsResultForHostSyncNonBlocking(host, type, null, null);
if (result == null) {
return Collections.emptyList();
}
List<String> ips = new ArrayList<>();
String[] v4 = result.getIps();
if (v4 != null) {
for (String item : v4) {
if (item != null && item.length() > 0) {
ips.add(item);
}
}
}
String[] v6 = result.getIpv6s();
if (v6 != null) {
for (String item : v6) {
if (item != null && item.length() > 0) {
ips.add(item);
}
}
}
return ips;
}
private byte[] readResponseBytes(HttpsURLConnection connection, int code) throws IOException {
InputStream stream = code >= 400 ? connection.getErrorStream() : connection.getInputStream();
if (stream == null) {
return new byte[0];
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[BUFFER_SIZE];
int count;
while ((count = stream.read(buffer)) > 0) {
out.write(buffer, 0, count);
}
stream.close();
return out.toByteArray();
}
private Map<String, List<String>> sanitizeHeaders(Map<String, List<String>> source) {
if (source == null || source.isEmpty()) {
return Collections.emptyMap();
}
Map<String, List<String>> result = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : source.entrySet()) {
if (entry.getKey() == null) {
continue;
}
result.put(entry.getKey(), entry.getValue());
}
return result;
}
private boolean allowsRequestBody(String method) {
if (method == null) {
return false;
}
String upper = method.toUpperCase();
return "POST".equals(upper) || "PUT".equals(upper) || "PATCH".equals(upper) || "DELETE".equals(upper);
}
private HostnameVerifier createHostnameVerifier(final String originalHost) {
if (options.isAllowInsecureCertificatesForDebugOnly()) {
return new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
return true;
}
};
}
final HostnameVerifier defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier();
return new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
return defaultVerifier.verify(originalHost, session);
}
};
}
private SSLSocketFactory createSocketFactory() throws IOException {
try {
SSLSocketFactory baseFactory;
if (options.isAllowInsecureCertificatesForDebugOnly()) {
baseFactory = trustAllSocketFactory();
} else {
baseFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
}
return new NoSniSocketFactory(baseFactory);
} catch (GeneralSecurityException e) {
throw new IOException("create ssl socket factory failed", e);
}
}
private SSLSocketFactory trustAllSocketFactory() throws GeneralSecurityException {
TrustManager[] managers = new TrustManager[]{new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}};
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, managers, new SecureRandom());
return context.getSocketFactory();
}
private static class NoSniSocketFactory extends SSLSocketFactory {
private final SSLSocketFactory delegate;
private NoSniSocketFactory(SSLSocketFactory delegate) {
this.delegate = delegate;
}
@Override
public String[] getDefaultCipherSuites() {
return delegate.getDefaultCipherSuites();
}
@Override
public String[] getSupportedCipherSuites() {
return delegate.getSupportedCipherSuites();
}
@Override
public java.net.Socket createSocket(java.net.Socket s, String host, int port, boolean autoClose)
throws IOException {
return sanitize(delegate.createSocket(s, host, port, autoClose));
}
@Override
public java.net.Socket createSocket(String host, int port) throws IOException {
return sanitize(delegate.createSocket(host, port));
}
@Override
public java.net.Socket createSocket(String host, int port, java.net.InetAddress localHost, int localPort)
throws IOException {
return sanitize(delegate.createSocket(host, port, localHost, localPort));
}
@Override
public java.net.Socket createSocket(java.net.InetAddress host, int port) throws IOException {
return sanitize(delegate.createSocket(host, port));
}
@Override
public java.net.Socket createSocket(java.net.InetAddress address, int port, java.net.InetAddress localAddress,
int localPort) throws IOException {
return sanitize(delegate.createSocket(address, port, localAddress, localPort));
}
private java.net.Socket sanitize(java.net.Socket socket) {
if (!(socket instanceof SSLSocket)) {
return socket;
}
SSLSocket sslSocket = (SSLSocket) socket;
try {
SSLParameters params = sslSocket.getSSLParameters();
try {
java.lang.reflect.Method setServerNames = SSLParameters.class.getMethod("setServerNames", List.class);
setServerNames.invoke(params, Collections.emptyList());
} catch (Throwable ignored) {
}
sslSocket.setSSLParameters(params);
} catch (Throwable ignored) {
}
return sslSocket;
}
}
}

View File

@@ -7,9 +7,7 @@ import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSession;
import com.alibaba.sdk.android.httpdns.log.HttpDnsLog;
@@ -61,13 +59,7 @@ public class HttpRequest<T> {
//设置通用UA
conn.setRequestProperty("User-Agent", requestConfig.getUA());
if (conn instanceof HttpsURLConnection) {
((HttpsURLConnection)conn).setHostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
return HttpsURLConnection.getDefaultHostnameVerifier().verify(
HttpRequestConfig.HTTPS_CERTIFICATE_HOSTNAME, session);
}
});
((HttpsURLConnection) conn).setHostnameVerifier(HttpsURLConnection.getDefaultHostnameVerifier());
}
if (conn.getResponseCode() != HttpURLConnection.HTTP_OK) {
in = conn.getErrorStream();

View File

@@ -1,25 +1,22 @@
package com.alibaba.sdk.android.httpdns.resolve;
import android.os.Build;
import android.text.TextUtils;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import com.alibaba.sdk.android.httpdns.BuildConfig;
import com.alibaba.sdk.android.httpdns.NetType;
import com.alibaba.sdk.android.httpdns.RequestIpType;
import com.alibaba.sdk.android.httpdns.impl.AESEncryptService;
import com.alibaba.sdk.android.httpdns.impl.HttpDnsConfig;
import com.alibaba.sdk.android.httpdns.impl.SignService;
import com.alibaba.sdk.android.httpdns.log.HttpDnsLog;
import com.alibaba.sdk.android.httpdns.request.HttpRequestConfig;
import com.alibaba.sdk.android.httpdns.track.SessionTrackMgr;
import org.json.JSONObject;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class ResolveHostHelper {
public static HttpRequestConfig getConfig(HttpDnsConfig config, String host,
@@ -28,18 +25,18 @@ public class ResolveHostHelper {
Map<String, String> globalParams,
SignService signService,
AESEncryptService encryptService) {
HashMap<String, String> extraArgs = null;
if (cacheKey != null) {
extraArgs = new HashMap<>();
HashMap<String, String> mergedExtras = null;
if ((globalParams != null && !globalParams.isEmpty()) || (extras != null && !extras.isEmpty())) {
mergedExtras = new HashMap<>();
if (globalParams != null) {
extraArgs.putAll(globalParams);
mergedExtras.putAll(globalParams);
}
if (extras != null) {
extraArgs.putAll(extras);
mergedExtras.putAll(extras);
}
}
String path = getPath(config, host, type, extraArgs, signService, encryptService);
String path = getPath(config, host, type, mergedExtras, signService, encryptService);
HttpRequestConfig requestConfig = getHttpRequestConfig(config, path, signService.isSignMode());
requestConfig.setUA(config.getUA());
requestConfig.setAESEncryptService(encryptService);
@@ -50,202 +47,62 @@ public class ResolveHostHelper {
Map<String, String> extras,
SignService signService,
AESEncryptService encryptService) {
//参数加密
String enc = "";
String query = getQuery(type);
String version = "1.0";
String tags = config.getBizTags();
AESEncryptService.EncryptionMode mode = AESEncryptService.EncryptionMode.PLAIN;
if (encryptService.isEncryptionMode()) {
String encryptJson = buildEncryptionStr(host, query, extras, tags);
if (HttpDnsLog.isPrint()) {
HttpDnsLog.d("encryptJson:" + encryptJson);
}
mode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? AESEncryptService.EncryptionMode.AES_GCM : AESEncryptService.EncryptionMode.AES_CBC;
enc = encryptService.encrypt(encryptJson, mode);
String qtype = getQType(type);
StringBuilder query = new StringBuilder();
appendQuery(query, "appId", config.getAccountId());
appendQuery(query, "dn", host);
appendQuery(query, "qtype", qtype);
appendQuery(query, "sdk_version", BuildConfig.VERSION_NAME);
appendQuery(query, "os", "android");
String sid = SessionTrackMgr.getInstance().getSessionId();
if (!TextUtils.isEmpty(sid)) {
appendQuery(query, "sid", sid);
}
String expireTime = signService.getExpireTime();
String queryStr = buildQueryStr(config.getAccountId(), mode.getMode(), host,
query, extras, enc, expireTime, version, tags);
if (HttpDnsLog.isPrint()) {
HttpDnsLog.d("query parameter:" + queryStr);
}
//加签
if (signService.isSignMode()) {
Map<String, String> signParamMap = new HashMap<>();
if (encryptService.isEncryptionMode()) {
signParamMap.put("enc", enc);
signParamMap.put("exp", expireTime);
signParamMap.put("id", config.getAccountId());
signParamMap.put("m", mode.getMode());
signParamMap.put("v", version);
}else {
signParamMap.put("dn", host);
signParamMap.put("exp", expireTime);
signParamMap.put("id", config.getAccountId());
signParamMap.put("m", mode.getMode());
if (!TextUtils.isEmpty(query)) {
signParamMap.put("q", query);
}
if (extras != null) {
for (Map.Entry<String, String> entry : extras.entrySet()) {
signParamMap.put("sdns-" + entry.getKey(), entry.getValue());
}
}
if (!TextUtils.isEmpty(tags)) {
signParamMap.put("tags", tags);
}
signParamMap.put("v", version);
}
String sign = signService.sign(signParamMap);
if (TextUtils.isEmpty(sign)) {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.d("param sign fail");
}
}else {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.d("sign:" + sign);
}
queryStr += "&s=" + sign;
}
}
String path = "/v2/d?" + queryStr + "&sdk=android_" + BuildConfig.VERSION_NAME + getSid();
if (HttpDnsLog.isPrint()) {
HttpDnsLog.d("path" + path);
}
return path;
}
private static String buildQueryStr(String accountId, String mode, String host,
String query, Map<String, String> extras, String enc,
String expireTime, String version, String tags) {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("id=").append(accountId);
stringBuilder.append("&m=").append(mode);
if (TextUtils.isEmpty(enc)) {
stringBuilder.append("&dn=").append(host);
if (!TextUtils.isEmpty(query)) {
stringBuilder.append("&q=").append(query);
}
String extra = getExtra(extras);
if (!TextUtils.isEmpty(extra)) {
stringBuilder.append(extra);
}
if (!TextUtils.isEmpty(tags)) {
stringBuilder.append("&tags=").append(tags);
}
}else {
stringBuilder.append("&enc=").append(enc);
}
stringBuilder.append("&v=").append(version);
stringBuilder.append("&exp=").append(expireTime);
return stringBuilder.toString();
}
private static String buildEncryptionStr(String host, String query, Map<String, String> extras, String tags) {
JSONObject json = new JSONObject();
try {
json.put("dn", host);
if (!TextUtils.isEmpty(query)) {
json.put("q", query);
}
if (!TextUtils.isEmpty(getExtra(extras))) {
for (Map.Entry<String, String> entry : extras.entrySet()) {
if (!checkKey(entry.getKey()) || !checkValue(entry.getValue())) {
continue;
}
json.put("sdns-" + entry.getKey(), entry.getValue());
}
}
if (!TextUtils.isEmpty(tags)) {
json.put("tags", tags);
}
} catch (Exception e) {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.e("encrypt param transfer to json fail.", e);
}
}
return json.toString();
}
private static String getQuery(RequestIpType type) {
String query = "";
switch (type) {
case v6:
query = "6";
break;
case both:
query = "4,6";
break;
default:
break;
}
return query;
}
private static String getExtra(Map<String, String> extras) {
StringBuilder sb = new StringBuilder();
boolean isKey = true;
boolean isValue = true;
if (extras != null) {
for (Map.Entry<String, String> entry : extras.entrySet()) {
sb.append("&sdns-");
sb.append(entry.getKey());
sb.append("=");
sb.append(entry.getValue());
if (!checkKey(entry.getKey())) {
isKey = false;
HttpDnsLog.e("设置自定义参数失败自定义key不合法" + entry.getKey());
break;
}
if (!checkValue(entry.getValue())) {
isValue = false;
HttpDnsLog.e("设置自定义参数失败自定义value不合法" + entry.getValue());
break;
}
String cip = extras.get("cip");
if (!TextUtils.isEmpty(cip)) {
appendQuery(query, "cip", cip);
}
} else {
return "";
}
if (isKey && isValue) {
String extra = sb.toString();
if (extra.getBytes(StandardCharsets.UTF_8).length <= 1000) {
return extra;
} else {
HttpDnsLog.e("设置自定义参数失败,自定义参数过长");
return "";
}
} else {
return "";
if (signService.isSignMode()) {
String exp = signService.getExpireTime();
String nonce = randomNonce();
String sign = signService.signResolve(config.getAccountId(), host, qtype, exp, nonce);
appendQuery(query, "exp", exp);
appendQuery(query, "nonce", nonce);
appendQuery(query, "sign", sign);
}
return "/resolve?" + query;
}
private static boolean checkKey(String s) {
return s.matches("[a-zA-Z0-9\\-_]+");
private static void appendQuery(StringBuilder query, String key, String value) {
if (TextUtils.isEmpty(key) || value == null) {
return;
}
if (query.length() > 0) {
query.append("&");
}
query.append(key).append("=").append(URLEncoder.encode(value, StandardCharsets.UTF_8));
}
private static boolean checkValue(String s) {
return s.matches("[a-zA-Z0-9\\-_=]+");
private static String getQType(RequestIpType type) {
if (type == RequestIpType.v6) {
return "AAAA";
}
return "A";
}
private static String randomNonce() {
return UUID.randomUUID().toString().replace("-", "");
}
public static HttpRequestConfig getConfig(HttpDnsConfig config, ArrayList<String> hostList,
RequestIpType type, SignService signService,
AESEncryptService encryptService) {
//拼接host
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < hostList.size(); i++) {
if (i != 0) {
stringBuilder.append(",");
}
stringBuilder.append(hostList.get(i));
String host = "";
if (hostList != null && hostList.size() > 0) {
host = hostList.get(0);
}
String host = stringBuilder.toString();
String path = getPath(config, host, type, null, signService, encryptService);
HttpRequestConfig requestConfig = getHttpRequestConfig(config, path, signService.isSignMode());
requestConfig.setUA(config.getUA());
@@ -268,14 +125,6 @@ public class ResolveHostHelper {
}
}
public static String getTags(HttpDnsConfig config) {
if (TextUtils.isEmpty(config.getBizTags())) {
return "";
}
return "&tags=" + config.getBizTags();
}
public static String getSid() {
String sessionId = SessionTrackMgr.getInstance().getSessionId();
if (sessionId == null) {
@@ -284,4 +133,14 @@ public class ResolveHostHelper {
return "&sid=" + sessionId;
}
}
/**
* 兼容可观测模块透传 tags。
*/
public static String getTags(HttpDnsConfig config) {
if (config == null || TextUtils.isEmpty(config.getBizTags())) {
return "";
}
return "&tags=" + config.getBizTags();
}
}

View File

@@ -1,23 +1,18 @@
package com.alibaba.sdk.android.httpdns.resolve;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import com.alibaba.sdk.android.httpdns.RequestIpType;
import com.alibaba.sdk.android.httpdns.impl.AESEncryptService;
import com.alibaba.sdk.android.httpdns.impl.HttpDnsConfig;
import com.alibaba.sdk.android.httpdns.impl.SignService;
import com.alibaba.sdk.android.httpdns.log.HttpDnsLog;
import com.alibaba.sdk.android.httpdns.request.BatchResolveHttpRequestStatusWatcher;
import com.alibaba.sdk.android.httpdns.request.HttpRequest;
import com.alibaba.sdk.android.httpdns.request.HttpRequestConfig;
import com.alibaba.sdk.android.httpdns.request.HttpRequestTask;
import com.alibaba.sdk.android.httpdns.request.HttpRequestWatcher;
import com.alibaba.sdk.android.httpdns.request.RequestCallback;
import com.alibaba.sdk.android.httpdns.request.RetryHttpRequest;
import com.alibaba.sdk.android.httpdns.serverip.RegionServerScheduleService;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
/**
* 发起域名解析请求
*/
@@ -43,9 +38,18 @@ public class ResolveHostRequestHandler {
public void requestResolveHost(final String host, final RequestIpType type,
Map<String, String> extras, final String cacheKey,
RequestCallback<ResolveHostResponse> callback) {
if (type == RequestIpType.both) {
requestResolveHostBoth(host, extras, cacheKey, callback);
return;
}
requestResolveHostSingle(host, type, extras, cacheKey, callback);
}
private void requestResolveHostSingle(final String host, final RequestIpType type,
Map<String, String> extras, final String cacheKey,
RequestCallback<ResolveHostResponse> callback) {
HttpRequestConfig requestConfig = ResolveHostHelper.getConfig(mHttpDnsConfig, host, type,
extras, cacheKey, mGlobalParams, mSignService, mAESEncryptService);
//补充可观测数据
requestConfig.setResolvingHost(host);
requestConfig.setResolvingIpType(type);
if (HttpDnsLog.isPrint()) {
@@ -54,30 +58,140 @@ public class ResolveHostRequestHandler {
mCategoryController.getCategory().resolve(mHttpDnsConfig, requestConfig, callback);
}
private void requestResolveHostBoth(final String host,
final Map<String, String> extras,
final String cacheKey,
final RequestCallback<ResolveHostResponse> callback) {
final ArrayList<ResolveHostResponse.HostItem> mergedItems = new ArrayList<>();
final String[] serverIpHolder = new String[]{""};
final Throwable[] errorHolder = new Throwable[]{null};
requestResolveHostSingle(host, RequestIpType.v4, extras, cacheKey, new RequestCallback<ResolveHostResponse>() {
@Override
public void onSuccess(ResolveHostResponse resolveHostResponse) {
mergeResponse(resolveHostResponse, mergedItems, serverIpHolder);
requestResolveHostSingle(host, RequestIpType.v6, extras, cacheKey, new RequestCallback<ResolveHostResponse>() {
@Override
public void onSuccess(ResolveHostResponse resolveHostResponse) {
mergeResponse(resolveHostResponse, mergedItems, serverIpHolder);
finishBothResolve(callback, mergedItems, serverIpHolder[0], errorHolder[0]);
}
@Override
public void onFail(Throwable throwable) {
if (errorHolder[0] == null) {
errorHolder[0] = throwable;
}
finishBothResolve(callback, mergedItems, serverIpHolder[0], errorHolder[0]);
}
});
}
@Override
public void onFail(Throwable throwable) {
errorHolder[0] = throwable;
requestResolveHostSingle(host, RequestIpType.v6, extras, cacheKey, new RequestCallback<ResolveHostResponse>() {
@Override
public void onSuccess(ResolveHostResponse resolveHostResponse) {
mergeResponse(resolveHostResponse, mergedItems, serverIpHolder);
finishBothResolve(callback, mergedItems, serverIpHolder[0], errorHolder[0]);
}
@Override
public void onFail(Throwable throwable) {
if (errorHolder[0] == null) {
errorHolder[0] = throwable;
}
finishBothResolve(callback, mergedItems, serverIpHolder[0], errorHolder[0]);
}
});
}
});
}
private void mergeResponse(ResolveHostResponse response,
ArrayList<ResolveHostResponse.HostItem> mergedItems,
String[] serverIpHolder) {
if (response == null) {
return;
}
if (response.getItems() != null && !response.getItems().isEmpty()) {
mergedItems.addAll(response.getItems());
}
if (serverIpHolder[0].isEmpty() && response.getServerIp() != null) {
serverIpHolder[0] = response.getServerIp();
}
}
private void finishBothResolve(RequestCallback<ResolveHostResponse> callback,
ArrayList<ResolveHostResponse.HostItem> mergedItems,
String serverIp,
Throwable error) {
if (callback == null) {
return;
}
if (!mergedItems.isEmpty()) {
callback.onSuccess(new ResolveHostResponse(mergedItems, serverIp));
return;
}
if (error != null) {
callback.onFail(error);
return;
}
callback.onSuccess(new ResolveHostResponse(new ArrayList<ResolveHostResponse.HostItem>(), serverIp));
}
public void requestResolveHost(final ArrayList<String> hostList, final RequestIpType type,
RequestCallback<ResolveHostResponse> callback) {
HttpRequestConfig requestConfig = ResolveHostHelper.getConfig(mHttpDnsConfig, hostList,
type, mSignService, mAESEncryptService);
requestConfig.setResolvingIpType(type);
if (callback == null) {
return;
}
if (hostList == null || hostList.isEmpty()) {
callback.onSuccess(new ResolveHostResponse(new ArrayList<ResolveHostResponse.HostItem>(), ""));
return;
}
if (HttpDnsLog.isPrint()) {
HttpDnsLog.d("start resolve hosts async for " + hostList.toString() + " " + type);
}
HttpRequest<ResolveHostResponse> request = new HttpRequest<>(
requestConfig, new ResolveHostResponseParser(mAESEncryptService));
request = new HttpRequestWatcher<>(request,
new BatchResolveHttpRequestStatusWatcher(mHttpDnsConfig.getObservableManager()));
// 切换服务IP更新服务IP
request = new HttpRequestWatcher<>(request, new ShiftServerWatcher(mHttpDnsConfig,
mScheduleService, mCategoryController));
// 重试一次
request = new RetryHttpRequest<>(request, 1);
try {
mHttpDnsConfig.getResolveWorker().execute(
new HttpRequestTask<>(request, callback));
} catch (Throwable e) {
callback.onFail(e);
final ArrayList<ResolveHostResponse.HostItem> allItems = new ArrayList<>();
final String[] serverIpHolder = new String[]{""};
requestResolveHostsSequentially(hostList, 0, type, allItems, serverIpHolder, callback);
}
private void requestResolveHostsSequentially(final ArrayList<String> hostList,
final int index,
final RequestIpType type,
final ArrayList<ResolveHostResponse.HostItem> allItems,
final String[] serverIpHolder,
final RequestCallback<ResolveHostResponse> callback) {
if (index >= hostList.size()) {
callback.onSuccess(new ResolveHostResponse(allItems, serverIpHolder[0]));
return;
}
final String host = hostList.get(index);
requestResolveHost(host, type, null, null, new RequestCallback<ResolveHostResponse>() {
@Override
public void onSuccess(ResolveHostResponse resolveHostResponse) {
if (resolveHostResponse != null) {
if (resolveHostResponse.getItems() != null) {
allItems.addAll(resolveHostResponse.getItems());
}
if (serverIpHolder[0].isEmpty()) {
serverIpHolder[0] = resolveHostResponse.getServerIp();
}
}
requestResolveHostsSequentially(hostList, index + 1, type, allItems, serverIpHolder, callback);
}
@Override
public void onFail(Throwable throwable) {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.w("batch resolve host fail: " + host, throwable);
}
requestResolveHostsSequentially(hostList, index + 1, type, allItems, serverIpHolder, callback);
}
});
}
/**
@@ -88,14 +202,14 @@ public class ResolveHostRequestHandler {
}
/**
* 设置嗅探模式请求时间间隔
* 设置嗅探模式请求时间间隔
*/
public void setSniffTimeInterval(int timeInterval) {
mCategoryController.setSniffTimeInterval(timeInterval);
}
/**
* 设置sdns的全局参数
* 设置 SDNS 全局参数(当前服务端不使用,保留兼容)
*/
public void setSdnsGlobalParams(Map<String, String> params) {
this.mGlobalParams.clear();
@@ -105,10 +219,9 @@ public class ResolveHostRequestHandler {
}
/**
* 清除sdns的全局参数
* 清除 SDNS 全局参数
*/
public void clearSdnsGlobalParams() {
this.mGlobalParams.clear();
}
}

View File

@@ -10,6 +10,7 @@ import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
/**
@@ -122,78 +123,47 @@ public class ResolveHostResponse {
}
public static ResolveHostResponse fromResponse(String serverIp, String body) throws JSONException {
ArrayList<HostItem> items = new ArrayList<>();
JSONObject jsonObject = new JSONObject(body);
if (jsonObject.has("answers")) {
JSONArray answers = jsonObject.getJSONArray("answers");
for (int i = 0; i < answers.length(); i++) {
JSONObject answer = answers.getJSONObject(i);
String hostName = null;
int ttl = 0;
String extra = null;
String[] ips = null;
String[] ipsv6 = null;
String noIpCode = null;
if (answer.has("dn")) {
hostName = answer.getString("dn");
JSONObject responseObject = new JSONObject(body);
JSONObject data = responseObject.optJSONObject("data");
if (data == null) {
return new ResolveHostResponse(items, serverIp);
}
String hostName = data.optString("domain");
int ttl = data.optInt("ttl", 60);
String summary = data.optString("summary", null);
JSONArray records = data.optJSONArray("records");
HashMap<RequestIpType, ArrayList<String>> typedIPs = new HashMap<>();
if (records != null) {
for (int i = 0; i < records.length(); i++) {
JSONObject record = records.optJSONObject(i);
if (record == null) {
continue;
}
if (answer.has("v4")) {
JSONObject ipv4 = answer.getJSONObject("v4");
if (ipv4.has("ips")) {
JSONArray ipArray = ipv4.getJSONArray("ips");
if (ipArray.length() != 0) {
ips = new String[ipArray.length()];
for (int j = 0; j < ipArray.length(); j++) {
ips[j] = ipArray.getString(j);
}
}
}
if (ipv4.has("ttl")) {
ttl = ipv4.getInt("ttl");
}
if (ipv4.has("extra")) {
extra = ipv4.getString("extra");
}
if (ipv4.has("no_ip_code")) {
noIpCode = ipv4.getString("no_ip_code");
}
items.add(new HostItem(hostName, RequestIpType.v4, ips, ttl, extra, noIpCode));
if (!TextUtils.isEmpty(noIpCode)) {
HttpDnsLog.w("RESOLVE FAIL, HOST:" + hostName + ", QUERY:4, "
+ "Msg:" + noIpCode);
}
String ip = record.optString("ip");
if (TextUtils.isEmpty(ip)) {
continue;
}
if (answer.has("v6")) {
JSONObject ipv6 = answer.getJSONObject("v6");
if (ipv6.has("ips")) {
JSONArray ipArray = ipv6.getJSONArray("ips");
if (ipArray.length() != 0) {
ipsv6 = new String[ipArray.length()];
for (int j = 0; j < ipArray.length(); j++) {
ipsv6[j] = ipArray.getString(j);
}
}
}
if (ipv6.has("ttl")) {
ttl = ipv6.getInt("ttl");
}
if (ipv6.has("extra")) {
extra = ipv6.getString("extra");
}
if (ipv6.has("no_ip_code")) {
noIpCode = ipv6.getString("no_ip_code");
}
items.add(new HostItem(hostName, RequestIpType.v6, ipsv6, ttl, extra, noIpCode));
if (!TextUtils.isEmpty(noIpCode)) {
HttpDnsLog.w("RESOLVE FAIL, HOST:" + hostName + ", QUERY:6, "
+ "Msg:" + noIpCode);
}
String recordType = record.optString("type", data.optString("qtype", "A")).toUpperCase();
RequestIpType ipType = "AAAA".equals(recordType) ? RequestIpType.v6 : RequestIpType.v4;
ArrayList<String> list = typedIPs.get(ipType);
if (list == null) {
list = new ArrayList<>();
typedIPs.put(ipType, list);
}
list.add(ip);
}
}
if (typedIPs.isEmpty()) {
String qtype = data.optString("qtype", "A");
RequestIpType ipType = "AAAA".equalsIgnoreCase(qtype) ? RequestIpType.v6 : RequestIpType.v4;
items.add(new HostItem(hostName, ipType, null, ttl, summary, null));
} else {
for (RequestIpType type : typedIPs.keySet()) {
ArrayList<String> ipList = typedIPs.get(type);
String[] ips = ipList == null ? null : ipList.toArray(new String[0]);
items.add(new HostItem(hostName, type, ips, ttl, summary, null));
}
}
return new ResolveHostResponse(items, serverIp);
}

View File

@@ -1,68 +1,27 @@
package com.alibaba.sdk.android.httpdns.resolve;
import android.text.TextUtils;
import com.alibaba.sdk.android.httpdns.impl.AESEncryptService;
import com.alibaba.sdk.android.httpdns.log.HttpDnsLog;
import com.alibaba.sdk.android.httpdns.request.ResponseParser;
import org.json.JSONObject;
public class ResolveHostResponseParser implements
ResponseParser<ResolveHostResponse> {
private final AESEncryptService mAESEncryptService;
public class ResolveHostResponseParser implements ResponseParser<ResolveHostResponse> {
public ResolveHostResponseParser(AESEncryptService aesEncryptService) {
mAESEncryptService = aesEncryptService;
// 新版协议固定 HTTPS不使用 AES 响应体解密,保留构造参数仅为兼容调用方。
}
@Override
public ResolveHostResponse parse(String serverIp, String response) throws Throwable {
String data = "";
JSONObject jsonResponse = new JSONObject(response);
if (jsonResponse.has("code")) {
String code = jsonResponse.getString("code");
if (TextUtils.equals(code, "success")) {
if (jsonResponse.has("data")) {
data = jsonResponse.getString("data");
if (!TextUtils.isEmpty(data)) {
//解密
AESEncryptService.EncryptionMode mode = AESEncryptService.EncryptionMode.PLAIN;
if (jsonResponse.has("mode")) {
String serverEncryptMode = jsonResponse.getString("mode");
if (TextUtils.equals(serverEncryptMode, AESEncryptService.EncryptionMode.AES_GCM.getMode())) {
mode = AESEncryptService.EncryptionMode.AES_GCM;
} else if (TextUtils.equals(serverEncryptMode, AESEncryptService.EncryptionMode.AES_CBC.getMode())) {
mode = AESEncryptService.EncryptionMode.AES_CBC;
}
}
data = mAESEncryptService.decrypt(data, mode);
if (TextUtils.isEmpty(data)) {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.e("response data decrypt fail");
}
}
} else {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.e("response data is empty");
}
}
}
} else {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.e("解析失败,原因为" + code);
}
throw new Exception(code);
}
}else {
if (HttpDnsLog.isPrint()) {
HttpDnsLog.e("response don't have code");
JSONObject responseObject = new JSONObject(response);
String code = responseObject.optString("code");
if (!"SUCCESS".equalsIgnoreCase(code)) {
String message = responseObject.optString("message");
if (message == null) {
message = "";
}
throw new Exception(code + ":" + message);
}
if (HttpDnsLog.isPrint()) {
HttpDnsLog.d("request success " + data);
}
return ResolveHostResponse.fromResponse(serverIp, data);
return ResolveHostResponse.fromResponse(serverIp, response);
}
}

View File

@@ -4,6 +4,8 @@ import android.content.Context;
import com.alibaba.sdk.android.httpdns.impl.HttpDnsInstanceHolder;
import com.alibaba.sdk.android.httpdns.impl.InstanceCreator;
import com.alibaba.sdk.android.httpdns.network.HttpDnsAdapterOptions;
import com.alibaba.sdk.android.httpdns.network.HttpDnsHttpAdapter;
import com.alibaba.sdk.android.httpdns.utils.CommonUtil;
/**
@@ -74,6 +76,20 @@ public class HttpDns {
InitConfig.addConfig(accountId, config);
}
/**
* Build official IP-direct + empty-SNI adapter.
*/
public static HttpDnsHttpAdapter buildHttpClientAdapter(HttpDnsService service) {
return new HttpDnsHttpAdapter(service);
}
/**
* Build official IP-direct + empty-SNI adapter with options.
*/
public static HttpDnsHttpAdapter buildHttpClientAdapter(HttpDnsService service, HttpDnsAdapterOptions options) {
return new HttpDnsHttpAdapter(service, options);
}
/**
* 启用或者禁用httpdns理论上这个是内部接口不给外部使用的
* 但是已经对外暴露,所以保留

View File

@@ -1,8 +1,8 @@
ext {
httpdnsDebugVersion = '2.6.7'
httpdnsDebugVersion = '1.0.0'
loggerVersion = '1.2.0'
crashDefendVersion = '0.0.6'
utdidVersion = '2.6.0'
ipdetectorVersion = '1.2.0'
}
}

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
set -e
ROOT=$(cd "$(dirname "$0")" && pwd)
DIST_DIR="$ROOT/dist"
TMP_DIR="$ROOT/.tmp_sdk_pkg"
function lookup_version() {
VERSION_FILE="$ROOT/../internal/const/const.go"
if [ ! -f "$VERSION_FILE" ]; then
echo "0.0.0"
return
fi
VERSION=$(grep -E 'Version[[:space:]]*=' "$VERSION_FILE" | head -n 1 | sed -E 's/.*"([0-9.]+)".*/\1/')
if [ -z "$VERSION" ]; then
echo "0.0.0"
else
echo "$VERSION"
fi
}
function ensure_cmd() {
CMD=$1
if ! command -v "$CMD" >/dev/null 2>&1; then
echo "missing command: $CMD"
exit 1
fi
}
function zip_dir() {
SRC_DIR=$1
ZIP_FILE=$2
(
cd "$SRC_DIR" || exit 1
zip -r -X -q "$ZIP_FILE" .
)
}
function build_android_sdk_package() {
echo "[sdk] building android aar ..."
ensure_cmd zip
if [ ! -x "$ROOT/android/gradlew" ]; then
echo "android gradlew not found: $ROOT/android/gradlew"
exit 1
fi
(
cd "$ROOT/android" || exit 1
./gradlew :httpdns-sdk:clean :httpdns-sdk:assembleNormalRelease
)
AAR_FILE=$(find "$ROOT/android/httpdns-sdk/build/outputs/aar" -type f -name "*normal-release*.aar" | head -n 1)
if [ -z "$AAR_FILE" ]; then
AAR_FILE=$(find "$ROOT/android/httpdns-sdk/build/outputs/aar" -type f -name "*release*.aar" | head -n 1)
fi
if [ -z "$AAR_FILE" ] || [ ! -f "$AAR_FILE" ]; then
echo "android aar is not generated"
exit 1
fi
PKG_DIR="$TMP_DIR/android"
rm -rf "$PKG_DIR"
mkdir -p "$PKG_DIR"
cp "$AAR_FILE" "$PKG_DIR/alicloud-android-httpdns.aar"
if [ -f "$ROOT/android/README.md" ]; then
cp "$ROOT/android/README.md" "$PKG_DIR/README.md"
fi
if [ -f "$ROOT/android/httpdns-sdk/proguard-rules.pro" ]; then
cp "$ROOT/android/httpdns-sdk/proguard-rules.pro" "$PKG_DIR/proguard-rules.pro"
fi
zip_dir "$PKG_DIR" "$DIST_DIR/httpdns-sdk-android.zip"
}
function build_ios_sdk_package() {
echo "[sdk] packaging ios xcframework ..."
ensure_cmd zip
CANDIDATES=(
"$ROOT/ios/dist/AlicloudHttpDNS.xcframework"
"$ROOT/ios/AlicloudHttpDNS.xcframework"
"$ROOT/ios/Build/AlicloudHttpDNS.xcframework"
)
XCFRAMEWORK_DIR=""
for path in "${CANDIDATES[@]}"; do
if [ -d "$path" ]; then
XCFRAMEWORK_DIR="$path"
break
fi
done
if [ -z "$XCFRAMEWORK_DIR" ]; then
echo "ios xcframework not found."
echo "please build it on macOS first, then place AlicloudHttpDNS.xcframework under EdgeHttpDNS/sdk/ios/dist/"
exit 1
fi
PKG_DIR="$TMP_DIR/ios"
rm -rf "$PKG_DIR"
mkdir -p "$PKG_DIR"
cp -R "$XCFRAMEWORK_DIR" "$PKG_DIR/"
if [ -f "$ROOT/ios/AlicloudHTTPDNS.podspec" ]; then
cp "$ROOT/ios/AlicloudHTTPDNS.podspec" "$PKG_DIR/"
fi
if [ -f "$ROOT/ios/README.md" ]; then
cp "$ROOT/ios/README.md" "$PKG_DIR/README.md"
fi
zip_dir "$PKG_DIR" "$DIST_DIR/httpdns-sdk-ios.zip"
}
function build_flutter_sdk_package() {
echo "[sdk] packaging flutter plugin ..."
ensure_cmd zip
PLUGIN_DIR="$ROOT/flutter/aliyun_httpdns"
if [ ! -d "$PLUGIN_DIR" ]; then
echo "flutter plugin directory not found: $PLUGIN_DIR"
exit 1
fi
PKG_DIR="$TMP_DIR/flutter"
rm -rf "$PKG_DIR"
mkdir -p "$PKG_DIR"
cp -R "$PLUGIN_DIR" "$PKG_DIR/"
rm -rf "$PKG_DIR/aliyun_httpdns/example/.dart_tool" "$PKG_DIR/aliyun_httpdns/example/build" "$PKG_DIR/aliyun_httpdns/.dart_tool" "$PKG_DIR/aliyun_httpdns/build"
zip_dir "$PKG_DIR" "$DIST_DIR/httpdns-sdk-flutter.zip"
}
function main() {
VERSION=$(lookup_version)
rm -rf "$TMP_DIR"
mkdir -p "$TMP_DIR" "$DIST_DIR"
build_android_sdk_package
build_ios_sdk_package
build_flutter_sdk_package
cp "$DIST_DIR/httpdns-sdk-android.zip" "$DIST_DIR/httpdns-sdk-android-v${VERSION}.zip"
cp "$DIST_DIR/httpdns-sdk-ios.zip" "$DIST_DIR/httpdns-sdk-ios-v${VERSION}.zip"
cp "$DIST_DIR/httpdns-sdk-flutter.zip" "$DIST_DIR/httpdns-sdk-flutter-v${VERSION}.zip"
rm -rf "$TMP_DIR"
echo "[sdk] done. output: $DIST_DIR"
}
main "$@"

View File

@@ -1,532 +1,73 @@
# Aliyun HTTPDNS Flutter Plugin
# HTTPDNS Flutter SDK (SNI Hidden v1.0.0)
阿里云EMAS HTTPDNS Flutter插件提供基于原生SDK的域名解析能力。
一、快速入门
-----------------------
### 1.1 开通服务
请参考[快速入门文档](https://help.aliyun.com/document_detail/2867674.html)开通HTTPDNS。
### 1.2 获取配置
请参考开发配置文档在EMAS控制台开发配置中获取AccountId/SecretKey/AESSecretKey等信息用于初始化SDK。
## 二、安装
`pubspec.yaml`中加入dependencies
```yaml
dependencies:
aliyun_httpdns: ^1.0.1
```
添加依赖之后需要执行一次 `flutter pub get`
### 原生SDK版本说明
插件已集成了对应平台的HTTPDNS原生SDK当前版本
- **Android**: `com.aliyun.ams:alicloud-android-httpdns:2.6.7`
- **iOS**: `AlicloudHTTPDNS:3.3.0`
三、配置和使用
------------------------
### 3.1 初始化配置
应用启动后需要先初始化插件才能调用HTTPDNS能力。
初始化主要是配置AccountId/SecretKey等信息及功能开关。
示例代码如下:
## 1. Initialization
```dart
// 初始化 HTTPDNS
await AliyunHttpdns.init(
accountId: '您的AccountId',
secretKey: '您的SecretKey',
);
// 设置功能选项
await AliyunHttpdns.setHttpsRequestEnabled(true);
await AliyunHttpdns.setLogEnabled(true);
await AliyunHttpdns.setPersistentCacheIPEnabled(true);
await AliyunHttpdns.setReuseExpiredIPEnabled(true);
// 构建服务
await AliyunHttpdns.build();
// 设置预解析域名
await AliyunHttpdns.setPreResolveHosts(['www.aliyun.com'], ipType: 'both');
print("init success");
```
#### 3.1.1 日志配置
应用开发过程中如果要输出HTTPDNS的日志可以调用日志输出控制方法开启日志示例代码如下
```dart
await AliyunHttpdns.setLogEnabled(true);
print("enableLog success");
```
#### 3.1.2 sessionId记录
应用在运行过程中可以调用获取SessionId方法获取sessionId记录到应用的数据采集系统中。
sessionId用于表示标识一次应用运行线上排查时可以用于查询应用一次运行过程中的解析日志示例代码如下
```dart
final sessionId = await AliyunHttpdns.getSessionId();
print("SessionId = $sessionId");
```
### 3.2 域名解析
#### 3.2.1 预解析
当需要提前解析域名时,可以调用预解析域名方法,示例代码如下:
```dart
await AliyunHttpdns.setPreResolveHosts(["www.aliyun.com", "www.example.com"], ipType: 'both');
print("preResolveHosts success");
```
调用之后,插件会发起域名解析,并把结果缓存到内存,用于后续请求时直接使用。
### 3.2.2 域名解析
当需要解析域名时可以通过调用域名解析方法解析域名获取IP示例代码如下
```dart
Future<void> _resolve() async {
final res = await AliyunHttpdns.resolveHostSyncNonBlocking('www.aliyun.com', ipType: 'both');
final ipv4List = res['ipv4'] ?? [];
final ipv6List = res['ipv6'] ?? [];
print('IPv4: $ipv4List');
print('IPv6: $ipv6List');
}
```
四、Flutter最佳实践
------------------------------
### 4.1 原理说明
本示例展示了一种更直接的集成方式通过自定义HTTP客户端适配器来实现HTTPDNS集成
1. 创建自定义的HTTP客户端适配器拦截网络请求
2. 在适配器中调用HTTPDNS插件解析域名为IP地址
3. 使用解析得到的IP地址创建直接的Socket连接
4. 对于HTTPS连接确保正确设置SNIServer Name Indication为原始域名
这种方式避免了创建本地代理服务的复杂性直接在HTTP客户端层面集成HTTPDNS功能。
### 4.2 示例说明
完整应用示例请参考插件包中example应用。
#### 4.2.1 自定义HTTP客户端适配器实现
自定义适配器的实现请参考插件包中example/lib/net/httpdns_http_client_adapter.dart文件。本方案由EMAS团队设计实现参考请注明出处。
适配器内部会拦截HTTP请求调用HTTPDNS进行域名解析并使用解析后的IP创建socket连接。
本示例支持三种网络库Dio、HttpClient、http包。代码如下
```dart
import 'dart:io';
import 'package:dio/io.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:flutter/foundation.dart';
import 'package:aliyun_httpdns/aliyun_httpdns.dart';
// Dio 适配器
IOHttpClientAdapter buildHttpdnsHttpClientAdapter() {
final HttpClient client = HttpClient();
_configureHttpClient(client);
_configureConnectionFactory(client);
final IOHttpClientAdapter adapter = IOHttpClientAdapter(createHttpClient: () => client)
..validateCertificate = (cert, host, port) => true;
return adapter;
}
// 原生 HttpClient
HttpClient buildHttpdnsNativeHttpClient() {
final HttpClient client = HttpClient();
_configureHttpClient(client);
_configureConnectionFactory(client);
return client;
}
// http 包适配器
http.Client buildHttpdnsHttpPackageClient() {
final HttpClient httpClient = buildHttpdnsNativeHttpClient();
return IOClient(httpClient);
}
// HttpClient 基础配置
void _configureHttpClient(HttpClient client) {
client.findProxy = (Uri _) => 'DIRECT';
client.idleTimeout = const Duration(seconds: 90);
client.maxConnectionsPerHost = 8;
}
// 配置基于 HTTPDNS 的连接工厂
// 本方案由EMAS团队设计实现参考请注明出处。
void _configureConnectionFactory(HttpClient client) {
client.connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) async {
final String domain = uri.host;
final bool https = uri.scheme.toLowerCase() == 'https';
final int port = uri.port == 0 ? (https ? 443 : 80) : uri.port;
final List<InternetAddress> targets = await _resolveTargets(domain);
final Object target = targets.isNotEmpty ? targets.first : domain;
if (!https) {
return Socket.startConnect(target, port);
}
// HTTPS先 TCP再 TLSSNI=域名),并保持可取消
bool cancelled = false;
final Future<ConnectionTask<Socket>> rawStart = Socket.startConnect(target, port);
final Future<Socket> upgraded = rawStart.then((task) async {
final Socket raw = await task.socket;
if (cancelled) {
raw.destroy();
throw const SocketException('Connection cancelled');
}
final SecureSocket secure = await SecureSocket.secure(
raw,
host: domain, // 重要使用原始域名作为SNI
);
if (cancelled) {
secure.destroy();
throw const SocketException('Connection cancelled');
}
return secure;
});
return ConnectionTask.fromSocket(
upgraded,
() {
cancelled = true;
try {
rawStart.then((t) => t.cancel());
} catch (_) {}
},
);
};
}
// 通过 HTTPDNS 解析目标 IP 列表
Future<List<InternetAddress>> _resolveTargets(String domain) async {
try {
final res = await AliyunHttpdns.resolveHostSyncNonBlocking(domain, ipType: 'both');
final List<String> ipv4 = res['ipv4'] ?? [];
final List<String> ipv6 = res['ipv6'] ?? [];
final List<InternetAddress> targets = [
...ipv4.map(InternetAddress.tryParse).whereType<InternetAddress>(),
...ipv6.map(InternetAddress.tryParse).whereType<InternetAddress>(),
];
if (targets.isEmpty) {
debugPrint('[dio] HTTPDNS no result for $domain, fallback to system DNS');
} else {
debugPrint('[dio] HTTPDNS resolved $domain -> ${targets.first.address}');
}
return targets;
} catch (e) {
debugPrint('[dio] HTTPDNS resolve failed: $e, fallback to system DNS');
return const <InternetAddress>[];
}
}
```
#### 4.2.2 适配器集成和使用
适配器的集成请参考插件包中example/lib/main.dart文件。
首先需要初始化HTTPDNS然后配置网络库使用自定义适配器示例代码如下
```dart
class _MyHomePageState extends State<MyHomePage> {
late final Dio _dio;
late final HttpClient _httpClient;
late final http.Client _httpPackageClient;
@override
void initState() {
super.initState();
// 初始化 HTTPDNS
_initHttpDnsOnce();
// 配置网络库使用 HTTPDNS 适配器
_dio = Dio();
_dio.httpClientAdapter = buildHttpdnsHttpClientAdapter();
_dio.options.headers['Connection'] = 'keep-alive';
_httpClient = buildHttpdnsNativeHttpClient();
_httpPackageClient = buildHttpdnsHttpPackageClient();
}
Future<void> _initHttpDnsOnce() async {
try {
await AliyunHttpdns.init(
accountId: 000000,
secretKey: '您的SecretKey',
);
await AliyunHttpdns.setHttpsRequestEnabled(true);
await AliyunHttpdns.setLogEnabled(true);
await AliyunHttpdns.setPersistentCacheIPEnabled(true);
await AliyunHttpdns.setReuseExpiredIPEnabled(true);
await AliyunHttpdns.build();
// 设置预解析域名
await AliyunHttpdns.setPreResolveHosts(['www.aliyun.com'], ipType: 'both');
} catch (e) {
debugPrint('[httpdns] init failed: $e');
}
}
}
```
使用配置好的网络库发起请求时会自动使用HTTPDNS进行域名解析
```dart
// 使用 Dio
final response = await _dio.get('https://www.aliyun.com');
// 使用 HttpClient
final request = await _httpClient.getUrl(Uri.parse('https://www.aliyun.com'));
final response = await request.close();
// 使用 http 包
final response = await _httpPackageClient.get(Uri.parse('https://www.aliyun.com'));
```
#### 4.2.3 资源清理
在组件销毁时,记得清理相关资源:
```dart
@override
void dispose() {
_urlController.dispose();
_httpClient.close();
_httpPackageClient.close();
super.dispose();
}
```
五、API
----------------------
### 5.1 日志输出控制
控制是否打印Log。
```dart
await AliyunHttpdns.setLogEnabled(true);
print("enableLog success");
```
### 5.2 初始化
初始化配置, 在应用启动时调用。
```dart
// 基础初始化
await AliyunHttpdns.init(
accountId: 000000,
secretKey: 'your_secret_key',
aesSecretKey: 'your_aes_secret_key', // 可选
appId: 'app1f1ndpo9',
primaryServiceHost: 'httpdns-a.example.com',
backupServiceHost: 'httpdns-b.example.com', // optional
servicePort: 443,
secretKey: 'your-sign-secret', // optional if sign is enabled
);
// 配置功能选项
await AliyunHttpdns.setHttpsRequestEnabled(true);
await AliyunHttpdns.setLogEnabled(true);
await AliyunHttpdns.setPersistentCacheIPEnabled(true);
await AliyunHttpdns.setReuseExpiredIPEnabled(true);
// 构建服务实例
await AliyunHttpdns.build();
print("init success");
```
初始化参数:
| 参数名 | 类型 | 是否必须 | 功能 | 支持平台 |
|-------------|--------|------|------------|-------------|
| accountId | int | 必选参数 | Account ID | Android/iOS |
| secretKey | String | 可选参数 | 加签密钥 | Android/iOS |
| aesSecretKey| String | 可选参数 | 加密密钥 | Android/iOS |
功能配置方法:
- `setHttpsRequestEnabled(bool)` - 设置是否使用HTTPS解析链路
- `setLogEnabled(bool)` - 设置是否开启日志
- `setPersistentCacheIPEnabled(bool)` - 设置是否开启持久化缓存
- `setReuseExpiredIPEnabled(bool)` - 设置是否允许复用过期IP
- `setPreResolveAfterNetworkChanged(bool)` - 设置网络切换时是否自动刷新解析
### 5.3 域名解析
解析指定域名。
## 2. Resolve
```dart
Future<void> _resolve() async {
final res = await AliyunHttpdns.resolveHostSyncNonBlocking(
'www.aliyun.com',
ipType: 'both', // 'auto', 'ipv4', 'ipv6', 'both'
);
final ipv4List = res['ipv4'] ?? [];
final ipv6List = res['ipv6'] ?? [];
print('IPv4: $ipv4List');
print('IPv6: $ipv6List');
}
```
参数:
| 参数名 | 类型 | 是否必须 | 功能 |
|------------|---------------------|------|----------------------------------------|
| hostname | String | 必选参数 | 要解析的域名 |
| ipType | String | 可选参数 | 请求IP类型: 'auto', 'ipv4', 'ipv6', 'both' |
返回数据结构:
| 字段名 | 类型 | 功能 |
|------|--------------|----------------------------------|
| ipv4 | List<String> | IPv4地址列表如: ["1.1.1.1", "2.2.2.2"] |
| ipv6 | List<String> | IPv6地址列表如: ["::1", "::2"] |
### 5.4 预解析域名
预解析域名, 解析后缓存在SDK中,下次解析时直接从缓存中获取,提高解析速度。
```dart
await AliyunHttpdns.setPreResolveHosts(
["www.aliyun.com", "www.example.com"],
ipType: 'both'
final result = await AliyunHttpdns.resolveHostSyncNonBlocking(
'api.business.com',
ipType: 'both',
);
print("preResolveHosts success");
final ipv4 = result['ipv4'] ?? <String>[];
final ipv6 = result['ipv6'] ?? <String>[];
```
参数:
| 参数名 | 类型 | 是否必须 | 功能 |
|--------|--------------|------|----------------------------------------|
| hosts | List<String> | 必选参数 | 预解析域名列表 |
| ipType | String | 可选参数 | 请求IP类型: 'auto', 'ipv4', 'ipv6', 'both' |
### 5.5 获取SessionId
获取SessionId, 用于排查追踪问题。
## 3. Official HTTP Adapter (IP + Host)
```dart
final sessionId = await AliyunHttpdns.getSessionId();
print("SessionId = $sessionId");
```
无需参数直接返回当前会话ID。
### 5.6 清除缓存
清除所有DNS解析缓存。
```dart
await AliyunHttpdns.cleanAllHostCache();
print("缓存清除成功");
```
### 5.7 持久化缓存配置
设置是否开启持久化缓存功能。开启后SDK 会将解析结果保存到本地App 重启后可以从本地加载缓存,提升首屏加载速度。
```dart
// 基础用法:开启持久化缓存
await AliyunHttpdns.setPersistentCacheIPEnabled(true);
// 高级用法:开启持久化缓存并设置过期时间阈值
await AliyunHttpdns.setPersistentCacheIPEnabled(
true,
discardExpiredAfterSeconds: 86400 // 1天单位
final adapter = AliyunHttpdns.createHttpAdapter(
options: const AliyunHttpdnsAdapterOptions(
ipType: 'auto',
connectTimeoutMs: 3000,
readTimeoutMs: 5000,
allowInsecureCertificatesForDebugOnly: false,
),
);
print("持久化缓存已开启");
final resp = await adapter.request(
Uri.parse('https://api.business.com/v1/ping'),
method: 'GET',
headers: {'Accept': 'application/json'},
);
print(resp.statusCode);
print(resp.usedIp);
```
参数:
Behavior is fixed:
- Resolve host by `/resolve`.
- Connect to resolved IP over HTTPS.
- Keep `Host` header as the real business domain.
- No fallback to domain direct request.
| 参数名 | 类型 | 是否必须 | 功能 |
|----------------------------|------|------|------------------------------------------|
| enabled | bool | 必选参数 | 是否开启持久化缓存 |
| discardExpiredAfterSeconds | int | 可选参数 | 过期时间阈值App 启动时会丢弃过期超过此时长的缓存记录,建议设置为 1 天86400 秒) |
注意事项:
- 持久化缓存仅影响第一次域名解析结果,后续解析仍会请求 HTTPDNS 服务器
- 如果业务服务器 IP 变化频繁,建议谨慎开启此功能
- 建议在 `build()` 之前调用此接口
### 5.8 网络变化时自动刷新预解析
设置在网络环境变化时是否自动刷新预解析域名的缓存。
```dart
await AliyunHttpdns.setPreResolveAfterNetworkChanged(true);
print("网络变化自动刷新已启用");
```
### 5.9 IP 优选
设置需要进行 IP 优选的域名列表。开启后SDK 会对解析返回的 IP 列表进行 TCP 测速并排序,优先返回连接速度最快的 IP。
```dart
await AliyunHttpdns.setIPRankingList({
'www.aliyun.com': 443,
});
print("IP 优选配置成功");
```
参数:
| 参数名 | 类型 | 是否必须 | 功能 |
|-------------|-----------------|------|------------------------------|
| hostPortMap | Map<String, int> | 必选参数 | 域名和端口的映射,例如:{'www.aliyun.com': 443} |
## 4. Public Errors
- `NO_IP_AVAILABLE`
- `TLS_EMPTY_SNI_FAILED`
- `HOST_ROUTE_REJECTED`
- `RESOLVE_SIGN_INVALID`
## 5. Deprecated Params Removed
The public init API no longer accepts:
- `accountId`
- `serviceDomain`
- `endpoint`
- `aesSecretKey`

View File

@@ -3,7 +3,6 @@ package com.aliyun.ams.httpdns
import android.content.Context
import android.util.Log
import androidx.annotation.NonNull
import com.alibaba.sdk.android.httpdns.HttpDns
import com.alibaba.sdk.android.httpdns.HttpDnsService
import com.alibaba.sdk.android.httpdns.InitConfig
@@ -15,18 +14,18 @@ import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
class AliyunHttpDnsPlugin : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
private var appContext: Context? = null
// Cached service keyed by accountId to avoid re-creating
private var service: HttpDnsService? = null
private var accountId: String? = null
private var secretKey: String? = null
private var aesSecretKey: String? = null
// Desired states collected before build()
private var appId: String? = null
private var secretKey: String? = null
private var primaryServiceHost: String? = null
private var backupServiceHost: String? = null
private var servicePort: Int? = null
private var desiredPersistentCacheEnabled: Boolean? = null
private var desiredDiscardExpiredAfterSeconds: Int? = null
private var desiredReuseExpiredIPEnabled: Boolean? = null
@@ -35,10 +34,6 @@ class AliyunHttpDnsPlugin : FlutterPlugin, MethodCallHandler {
private var desiredPreResolveAfterNetworkChanged: Boolean? = null
private var desiredIPRankingMap: Map<String, Int>? = null
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
appContext = flutterPluginBinding.applicationContext
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "aliyun_httpdns")
@@ -46,14 +41,7 @@ class AliyunHttpDnsPlugin : FlutterPlugin, MethodCallHandler {
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
// Log every incoming call with method name and raw arguments
try {
Log.i("AliyunHttpDns", "invoke method=${call.method}, args=${call.arguments}")
} catch (_: Throwable) {
Log.i("AliyunHttpDns", "invoke method=${call.method}, args=<unprintable>")
}
when (call.method) {
// Dart: init(accountId, secretKey?, aesSecretKey?) — only save states here
"initialize" -> {
val args = call.arguments as? Map<*, *> ?: emptyMap<String, Any>()
val ctx = appContext
@@ -61,261 +49,315 @@ class AliyunHttpDnsPlugin : FlutterPlugin, MethodCallHandler {
result.error("no_context", "Android context not attached", null)
return
}
val accountAny = args["accountId"]
val account = when (accountAny) {
is Int -> accountAny.toString()
is Long -> accountAny.toString()
is String -> accountAny
else -> null
}
val secret = (args["secretKey"] as? String)?.takeIf { it.isNotBlank() }
val aes = (args["aesSecretKey"] as? String)?.takeIf { it.isNotBlank() }
if (account.isNullOrBlank()) {
Log.i("AliyunHttpDns", "initialize missing accountId")
val appIdAny = args["appId"]
val parsedAppId = when (appIdAny) {
is Int -> appIdAny.toString()
is Long -> appIdAny.toString()
is String -> appIdAny.trim()
else -> ""
}
if (parsedAppId.isBlank()) {
Log.i("AliyunHttpDns", "initialize missing appId")
result.success(false)
return
}
// Save desired states only; actual build happens on 'build'
accountId = account
val primaryHostArg = (args["primaryServiceHost"] as? String)?.trim()
if (primaryHostArg.isNullOrBlank()) {
Log.i("AliyunHttpDns", "initialize missing primaryServiceHost")
result.success(false)
return
}
val secret = (args["secretKey"] as? String)?.takeIf { it.isNotBlank() }
val backup = (args["backupServiceHost"] as? String)?.trim()
val port = when (val portAny = args["servicePort"]) {
is Int -> portAny
is Long -> portAny.toInt()
is String -> portAny.toIntOrNull()
else -> null
}
appId = parsedAppId
secretKey = secret
aesSecretKey = aes
Log.i("AliyunHttpDns", "initialize saved state, account=$account")
primaryServiceHost = primaryHostArg
backupServiceHost = backup?.trim()?.takeIf { it.isNotEmpty() }
servicePort = if (port != null && port > 0) port else null
Log.i(
"AliyunHttpDns",
"initialize appId=$appId, primaryServiceHost=$primaryServiceHost, backupServiceHost=$backupServiceHost, servicePort=$servicePort"
)
result.success(true)
}
// Dart: setLogEnabled(enabled) — save desired
"setLogEnabled" -> {
val enabled = call.argument<Boolean>("enabled") == true
desiredLogEnabled = enabled
Log.i("AliyunHttpDns", "setLogEnabled desired=$enabled")
result.success(null)
}
// Dart: setHttpsRequestEnabled(enabled)
"setHttpsRequestEnabled" -> {
val enabled = call.argument<Boolean>("enabled") == true
desiredHttpsEnabled = enabled
Log.i("AliyunHttpDns", "https request desired=$enabled")
result.success(null)
}
// Dart: setPersistentCacheIPEnabled(enabled, discardExpiredAfterSeconds?) — save desired
"setPersistentCacheIPEnabled" -> {
val enabled = call.argument<Boolean>("enabled") == true
val discard = call.argument<Int>("discardExpiredAfterSeconds")
desiredPersistentCacheEnabled = enabled
desiredDiscardExpiredAfterSeconds = discard
Log.i("AliyunHttpDns", "persistent cache desired=$enabled discard=$discard")
result.success(null)
}
// Dart: setReuseExpiredIPEnabled(enabled) — save desired
"setReuseExpiredIPEnabled" -> {
val enabled = call.argument<Boolean>("enabled") == true
desiredReuseExpiredIPEnabled = enabled
Log.i("AliyunHttpDns", "reuse expired ip desired=$enabled")
result.success(null)
}
// Dart: setPreResolveAfterNetworkChanged(enabled) — save desired (applied at build via InitConfig)
"setPreResolveAfterNetworkChanged" -> {
val enabled = call.argument<Boolean>("enabled") == true
desiredPreResolveAfterNetworkChanged = enabled
Log.i("AliyunHttpDns", "preResolveAfterNetworkChanged desired=$enabled")
result.success(null)
}
// Dart: setIPRankingList(hostPortMap) — save desired
"setIPRankingList" -> {
val hostPortMap = call.argument<Map<String, Int>>("hostPortMap")
desiredIPRankingMap = hostPortMap
Log.i("AliyunHttpDns", "IP ranking list desired, hosts=${hostPortMap?.keys?.joinToString()}")
result.success(null)
}
// Dart: setPreResolveHosts(hosts, ipType)
"setPreResolveHosts" -> {
val hosts = call.argument<List<String>>("hosts") ?: emptyList()
val ipTypeStr = call.argument<String>("ipType") ?: "auto"
val type = when (ipTypeStr.lowercase()) {
"ipv4", "v4" -> RequestIpType.v4
"ipv6", "v6" -> RequestIpType.v6
"both", "64" -> RequestIpType.both
else -> RequestIpType.auto
}
val type = mapIpType(call.argument<String>("ipType") ?: "auto")
try {
service?.setPreResolveHosts(hosts, type)
Log.i("AliyunHttpDns", "preResolve set for ${hosts.size} hosts, type=$type")
} catch (t: Throwable) {
Log.i("AliyunHttpDns", "setPreResolveHosts failed: ${t.message}")
} catch (_: Throwable) {
}
result.success(null)
}
// Dart: getSessionId
"getSessionId" -> {
val sid = try { service?.getSessionId() } catch (_: Throwable) { null }
val sid = try {
service?.getSessionId()
} catch (_: Throwable) {
null
}
result.success(sid)
}
// Dart: cleanAllHostCache
"cleanAllHostCache" -> {
try {
// Best-effort: empty list to clear all
service?.cleanHostCache(ArrayList())
} catch (t: Throwable) {
Log.i("AliyunHttpDns", "cleanAllHostCache failed: ${t.message}")
} catch (_: Throwable) {
}
result.success(null)
}
// Dart: build() — construct InitConfig and acquire service using desired states
"build" -> {
val ctx = appContext
val account = accountId
if (ctx == null || account.isNullOrBlank()) {
val currentAppId = appId
if (ctx == null || currentAppId.isNullOrBlank()) {
result.success(false)
return
}
try {
desiredLogEnabled?.let { enabled ->
try {
HttpDnsLog.enable(enabled)
Log.i("AliyunHttpDns", "HttpDnsLog.enable($enabled)")
} catch (t: Throwable) {
Log.w("AliyunHttpDns", "HttpDnsLog.enable failed: ${t.message}")
}
}
val builder = InitConfig.Builder()
// Optional builder params
try { builder.javaClass.getMethod("setContext", Context::class.java).invoke(builder, ctx) } catch (_: Throwable) {}
try {
desiredLogEnabled?.let { HttpDnsLog.enable(it) }
val builder = InitConfig.Builder()
var hostConfigApplied = false
try {
builder.javaClass.getMethod("setContext", Context::class.java)
.invoke(builder, ctx)
} catch (_: Throwable) {
}
try {
if (!secretKey.isNullOrBlank()) {
builder.javaClass.getMethod("setSecretKey", String::class.java).invoke(builder, secretKey)
builder.javaClass.getMethod("setSecretKey", String::class.java)
.invoke(builder, secretKey)
}
} catch (_: Throwable) {}
} catch (_: Throwable) {
}
try {
if (!aesSecretKey.isNullOrBlank()) {
builder.javaClass.getMethod("setAesSecretKey", String::class.java).invoke(builder, aesSecretKey)
}
} catch (_: Throwable) {}
// Prefer HTTPS if requested
val enableHttps = desiredHttpsEnabled ?: true
builder.javaClass.getMethod("setEnableHttps", Boolean::class.javaPrimitiveType)
.invoke(builder, enableHttps)
} catch (_: Throwable) {
}
try {
desiredHttpsEnabled?.let { en ->
builder.javaClass.getMethod("setEnableHttps", Boolean::class.javaPrimitiveType).invoke(builder, en)
builder.javaClass.getMethod("setPrimaryServiceHost", String::class.java)
.invoke(builder, primaryServiceHost)
hostConfigApplied = true
} catch (t: Throwable) {
Log.w("AliyunHttpDns", "setPrimaryServiceHost failed: ${t.message}")
}
try {
backupServiceHost?.let {
builder.javaClass.getMethod("setBackupServiceHost", String::class.java)
.invoke(builder, it)
}
} catch (_: Throwable) {}
} catch (_: Throwable) {
}
try {
servicePort?.let {
builder.javaClass.getMethod("setServicePort", Int::class.javaPrimitiveType)
.invoke(builder, it)
}
} catch (_: Throwable) {
}
try {
desiredPersistentCacheEnabled?.let { enabled ->
val discardSeconds = desiredDiscardExpiredAfterSeconds
if (discardSeconds != null && discardSeconds >= 0) {
val expiredThresholdMillis = discardSeconds.toLong() * 1000L
builder.javaClass.getMethod("setEnableCacheIp", Boolean::class.javaPrimitiveType, Long::class.javaPrimitiveType)
.invoke(builder, enabled, expiredThresholdMillis)
val thresholdMillis = discardSeconds.toLong() * 1000L
builder.javaClass.getMethod(
"setEnableCacheIp",
Boolean::class.javaPrimitiveType,
Long::class.javaPrimitiveType
).invoke(builder, enabled, thresholdMillis)
} else {
builder.javaClass.getMethod("setEnableCacheIp", Boolean::class.javaPrimitiveType)
.invoke(builder, enabled)
builder.javaClass.getMethod(
"setEnableCacheIp",
Boolean::class.javaPrimitiveType
).invoke(builder, enabled)
}
}
} catch (_: Throwable) { }
} catch (_: Throwable) {
}
try {
desiredReuseExpiredIPEnabled?.let { enabled ->
desiredReuseExpiredIPEnabled?.let {
builder.javaClass.getMethod("setEnableExpiredIp", Boolean::class.javaPrimitiveType)
.invoke(builder, enabled)
.invoke(builder, it)
}
} catch (_: Throwable) { }
// Apply preResolve-after-network-changed
} catch (_: Throwable) {
}
try {
desiredPreResolveAfterNetworkChanged?.let { en ->
builder.javaClass.getMethod("setPreResolveAfterNetworkChanged", Boolean::class.javaPrimitiveType).invoke(builder, en)
desiredPreResolveAfterNetworkChanged?.let {
builder.javaClass.getMethod("setPreResolveAfterNetworkChanged", Boolean::class.javaPrimitiveType)
.invoke(builder, it)
}
} catch (_: Throwable) {}
// Apply IP ranking list
} catch (_: Throwable) {
}
try {
desiredIPRankingMap?.let { map ->
if (map.isNotEmpty()) {
// Create List<IPRankingBean>
val ipRankingBeanClass = Class.forName("com.alibaba.sdk.android.httpdns.ranking.IPRankingBean")
val constructor = ipRankingBeanClass.getConstructor(String::class.java, Int::class.javaPrimitiveType)
val beanClass = Class.forName("com.alibaba.sdk.android.httpdns.ranking.IPRankingBean")
val ctor = beanClass.getConstructor(String::class.java, Int::class.javaPrimitiveType)
val list = ArrayList<Any>()
map.forEach { (host, port) ->
val bean = constructor.newInstance(host, port)
list.add(bean)
list.add(ctor.newInstance(host, port))
}
val m = builder.javaClass.getMethod("setIPRankingList", List::class.java)
m.invoke(builder, list)
Log.i("AliyunHttpDns", "setIPRankingList applied with ${list.size} hosts")
builder.javaClass.getMethod("setIPRankingList", List::class.java)
.invoke(builder, list)
}
}
} catch (t: Throwable) {
Log.w("AliyunHttpDns", "setIPRankingList failed: ${t.message}")
} catch (_: Throwable) {
}
builder.buildFor(account)
if (!hostConfigApplied) {
Log.w("AliyunHttpDns", "build failed: sdk core does not support primaryServiceHost")
result.success(false)
return
}
builder.buildFor(currentAppId)
service = if (!secretKey.isNullOrBlank()) {
HttpDns.getService(ctx, account, secretKey)
HttpDns.getService(ctx, currentAppId, secretKey)
} else {
HttpDns.getService(ctx, account)
HttpDns.getService(ctx, currentAppId)
}
Log.i("AliyunHttpDns", "build completed for account=$account")
result.success(true)
} catch (t: Throwable) {
Log.i("AliyunHttpDns", "build failed: ${t.message}")
Log.w("AliyunHttpDns", "build failed: ${t.message}")
result.success(false)
}
}
// Dart: resolveHostSyncNonBlocking(hostname, ipType, sdnsParams?, cacheKey?)
"resolveHostSyncNonBlocking" -> {
val hostname = call.argument<String>("hostname")
if (hostname.isNullOrBlank()) {
result.success(mapOf("ipv4" to emptyList<String>(), "ipv6" to emptyList<String>()))
return
}
val ipTypeStr = call.argument<String>("ipType") ?: "auto"
val type = when (ipTypeStr.lowercase()) {
"ipv4", "v4" -> RequestIpType.v4
"ipv6", "v6" -> RequestIpType.v6
"both", "64" -> RequestIpType.both
else -> RequestIpType.auto
}
val type = mapIpType(call.argument<String>("ipType") ?: "auto")
try {
val svc = service ?: run {
val ctx = appContext
val acc = accountId
if (ctx != null && !acc.isNullOrBlank()) HttpDns.getService(ctx, acc) else null
val currentAppId = appId
if (ctx != null && !currentAppId.isNullOrBlank()) {
HttpDns.getService(ctx, currentAppId)
} else {
null
}
}
val r = svc?.getHttpDnsResultForHostSyncNonBlocking(hostname, type)
val v4 = r?.ips?.toList() ?: emptyList()
val v6 = r?.ipv6s?.toList() ?: emptyList()
// 记录解析结果,便于排查:包含 host、请求类型以及返回的 IPv4/IPv6 列表
Log.d(
"HttpdnsPlugin",
"resolve result host=" + hostname + ", type=" + type +
", ipv4=" + v4.joinToString(prefix = "[", postfix = "]") +
", ipv6=" + v6.joinToString(prefix = "[", postfix = "]")
)
result.success(mapOf("ipv4" to v4, "ipv6" to v6))
} catch (t: Throwable) {
Log.i("AliyunHttpDns", "resolveHostSyncNonBlocking failed: ${t.message}")
} catch (_: Throwable) {
result.success(mapOf("ipv4" to emptyList<String>(), "ipv6" to emptyList<String>()))
}
}
// Legacy methods removed: preResolve / clearCache handled at app layer if needed
"resolveHostV1" -> {
val hostname = call.argument<String>("hostname")
if (hostname.isNullOrBlank()) {
result.success(mapOf("ipv4" to emptyList<String>(), "ipv6" to emptyList<String>(), "ttl" to 0))
return
}
val qtype = (call.argument<String>("qtype") ?: "A").uppercase()
val cip = call.argument<String>("cip")?.trim()
val requestType = if (qtype == "AAAA") RequestIpType.v6 else RequestIpType.v4
try {
val svc = service ?: run {
val ctx = appContext
val currentAppId = appId
if (ctx != null && !currentAppId.isNullOrBlank()) {
HttpDns.getService(ctx, currentAppId)
} else {
null
}
}
val params = if (!cip.isNullOrEmpty()) mapOf("cip" to cip) else null
val r = svc?.getHttpDnsResultForHostSyncNonBlocking(hostname, requestType, params, hostname)
val v4 = r?.ips?.toList() ?: emptyList()
val v6 = r?.ipv6s?.toList() ?: emptyList()
result.success(
mapOf(
"ipv4" to v4,
"ipv6" to v6,
"ttl" to (r?.ttl ?: 0),
)
)
} catch (_: Throwable) {
result.success(mapOf("ipv4" to emptyList<String>(), "ipv6" to emptyList<String>(), "ttl" to 0))
}
}
else -> result.notImplemented()
}
}
private fun mapIpType(ipType: String): RequestIpType {
return when (ipType.lowercase()) {
"ipv4", "v4" -> RequestIpType.v4
"ipv6", "v6" -> RequestIpType.v6
"both", "64" -> RequestIpType.both
else -> RequestIpType.auto
}
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
service = null

View File

@@ -16,11 +16,14 @@ This example demonstrates:
## Getting Started
1. Replace the `accountId` and `secretKey` in `lib/main.dart` with your own credentials:
1. Replace the SDK init parameters in `lib/main.dart` with your own values:
```dart
await AliyunHttpdns.init(
accountId: YOUR_ACCOUNT_ID, // Replace with your account ID
secretKey: 'YOUR_SECRET_KEY', // Replace with your secret key
appId: 'YOUR_APP_ID',
primaryServiceHost: 'httpdns-a.example.com',
backupServiceHost: 'httpdns-b.example.com',
servicePort: 443,
secretKey: 'YOUR_SIGN_SECRET', // optional if sign is enabled
);
```
@@ -48,4 +51,4 @@ The example uses a modern approach with `HttpClient.connectionFactory` to integr
## Note
The credentials in this example are placeholders. Please obtain your own credentials from the [Aliyun HTTPDNS Console](https://help.aliyun.com/document_detail/2867674.html).
The values in this example are placeholders. Use your own platform app configuration.

View File

@@ -64,8 +64,11 @@ class _MyHomePageState extends State<MyHomePage> {
_httpdnsIniting = true;
try {
await AliyunHttpdns.init(
accountId: 000000, // 请替换为您的 Account ID
secretKey: 'your_secret_key_here', // 请替换为您的 Secret Key
appId: 'app1f1ndpo9', // 请替换为您的应用 AppId
primaryServiceHost: 'httpdns-a.example.com', // 请替换为主服务域名
backupServiceHost: 'httpdns-b.example.com', // 可选:备服务域名
servicePort: 443,
secretKey: 'your_sign_secret_here', // 可选:仅验签开启时需要
);
await AliyunHttpdns.setHttpsRequestEnabled(true);
await AliyunHttpdns.setLogEnabled(true);

View File

@@ -1,203 +1,324 @@
import Flutter
import Foundation
import UIKit
import AlicloudHTTPDNS
import CommonCrypto
public class AliyunHttpDnsPlugin: NSObject, FlutterPlugin {
private var channel: FlutterMethodChannel!
private var appId: String?
private var secretKey: String?
private var primaryServiceHost: String?
private var backupServiceHost: String?
private var servicePort: Int = 443
// Desired states saved until build()
private var desiredAccountId: Int?
private var desiredSecretKey: String?
private var desiredAesSecretKey: String?
private var desiredPersistentCacheEnabled: Bool?
private var desiredDiscardExpiredAfterSeconds: Int?
private var desiredReuseExpiredIPEnabled: Bool?
private var desiredLogEnabled: Bool?
private var desiredHttpsEnabled: Bool?
private var desiredPreResolveAfterNetworkChanged: Bool?
private var desiredIPRankingMap: [String: NSNumber]?
private var desiredConnectTimeoutSeconds: TimeInterval = 3
private var desiredReadTimeoutSeconds: TimeInterval = 5
private lazy var sessionId: String = {
UUID().uuidString.replacingOccurrences(of: "-", with: "")
}()
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "aliyun_httpdns", binaryMessenger: registrar.messenger())
let instance = AliyunHttpDnsPlugin()
instance.channel = channel
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
// Dart: init(accountId, secretKey?, aesSecretKey?) only save desired state
case "initialize":
let options = call.arguments as? [String: Any] ?? [:]
let accountIdAny = options["accountId"]
let secretKey = options["secretKey"] as? String
let aesSecretKey = options["aesSecretKey"] as? String
guard let accountId = (accountIdAny as? Int) ?? Int((accountIdAny as? String) ?? "") else {
NSLog("AliyunHttpDns: initialize missing accountId")
guard let rawAppId = options["appId"] as? String,
let rawPrimaryHost = options["primaryServiceHost"] as? String else {
result(false)
return
}
desiredAccountId = accountId
desiredSecretKey = secretKey
desiredAesSecretKey = aesSecretKey
NSLog("AliyunHttpDns: initialize saved accountId=\(accountId)")
let normalizedAppId = rawAppId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPrimaryHost = rawPrimaryHost.trimmingCharacters(in: .whitespacesAndNewlines)
if normalizedAppId.isEmpty || normalizedPrimaryHost.isEmpty {
result(false)
return
}
appId = normalizedAppId
primaryServiceHost = normalizedPrimaryHost
backupServiceHost = (options["backupServiceHost"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
secretKey = (options["secretKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let p = options["servicePort"] as? Int, p > 0 {
servicePort = p
}
result(true)
// Dart: setLogEnabled(enabled) save desired
case "setLogEnabled":
let args = call.arguments as? [String: Any]
let enabled = (args?["enabled"] as? Bool) ?? false
desiredLogEnabled = enabled
NSLog("AliyunHttpDns: log desired=\(enabled)")
desiredLogEnabled = (args?["enabled"] as? Bool) ?? false
result(nil)
case "setHttpsRequestEnabled":
let args = call.arguments as? [String: Any]
let enabled = (args?["enabled"] as? Bool) ?? false
desiredHttpsEnabled = enabled
NSLog("AliyunHttpDns: https request desired=\(enabled)")
desiredHttpsEnabled = (args?["enabled"] as? Bool) ?? false
result(nil)
// Dart: setPersistentCacheIPEnabled(enabled, discardExpiredAfterSeconds?) save desired
case "setPersistentCacheIPEnabled":
let args = call.arguments as? [String: Any]
let enabled = (args?["enabled"] as? Bool) ?? false
let discard = args?["discardExpiredAfterSeconds"] as? Int
desiredPersistentCacheEnabled = enabled
desiredDiscardExpiredAfterSeconds = discard
NSLog("AliyunHttpDns: persistent cache desired=\(enabled) discard=\(discard ?? -1)")
result(nil)
// Dart: setReuseExpiredIPEnabled(enabled) save desired
case "setReuseExpiredIPEnabled":
let args = call.arguments as? [String: Any]
let enabled = (args?["enabled"] as? Bool) ?? false
desiredReuseExpiredIPEnabled = enabled
NSLog("AliyunHttpDns: reuse expired ip desired=\(enabled)")
result(nil)
case "setPreResolveAfterNetworkChanged":
let args = call.arguments as? [String: Any]
let enabled = (args?["enabled"] as? Bool) ?? false
desiredPreResolveAfterNetworkChanged = enabled
NSLog("AliyunHttpDns: preResolveAfterNetworkChanged desired=\(enabled)")
result(nil)
case "setIPRankingList":
let args = call.arguments as? [String: Any]
let hostPortMap = args?["hostPortMap"] as? [String: NSNumber]
desiredIPRankingMap = hostPortMap
NSLog("AliyunHttpDns: IP ranking list desired, hosts=\(hostPortMap?.keys.joined(separator: ", ") ?? "")")
result(nil)
case "setPreResolveHosts":
let args = call.arguments as? [String: Any]
let hosts = (args?["hosts"] as? [String]) ?? []
let ipTypeStr = (args?["ipType"] as? String) ?? "auto"
switch ipTypeStr.lowercased() {
case "ipv4", "v4":
HttpDnsService.sharedInstance().setPreResolveHosts(hosts, queryIPType: AlicloudHttpDNS_IPType.init(0))
case "ipv6", "v6":
HttpDnsService.sharedInstance().setPreResolveHosts(hosts, queryIPType: AlicloudHttpDNS_IPType.init(1))
case "both", "64":
HttpDnsService.sharedInstance().setPreResolveHosts(hosts, queryIPType: AlicloudHttpDNS_IPType.init(2))
default:
HttpDnsService.sharedInstance().setPreResolveHosts(hosts)
}
result(nil)
case "getSessionId":
let sid = HttpDnsService.sharedInstance().getSessionId()
result(sid)
result(sessionId)
case "cleanAllHostCache":
HttpDnsService.sharedInstance().cleanAllHostCache()
result(nil)
// Dart: build() construct service and apply desired states
case "build":
guard let accountId = desiredAccountId else {
result(false)
return
if desiredHttpsEnabled == false {
NSLog("AliyunHttpDns(iOS): HTTPS is required by current protocol, force enabled")
}
// Initialize singleton
if let secret = desiredSecretKey, !secret.isEmpty {
if let aes = desiredAesSecretKey, !aes.isEmpty {
_ = HttpDnsService(accountID: accountId, secretKey: secret, aesSecretKey: aes)
} else {
_ = HttpDnsService(accountID: accountId, secretKey: secret)
}
} else {
_ = HttpDnsService(accountID: accountId) // deprecated but acceptable fallback
}
let svc = HttpDnsService.sharedInstance()
// Apply desired runtime flags
if let enable = desiredPersistentCacheEnabled {
if let discard = desiredDiscardExpiredAfterSeconds, discard >= 0 {
svc.setPersistentCacheIPEnabled(enable, discardRecordsHasExpiredFor: TimeInterval(discard))
} else {
svc.setPersistentCacheIPEnabled(enable)
}
}
if let enable = desiredReuseExpiredIPEnabled {
svc.setReuseExpiredIPEnabled(enable)
}
if let enable = desiredLogEnabled {
svc.setLogEnabled(enable)
}
if let enable = desiredHttpsEnabled {
svc.setHTTPSRequestEnabled(enable)
if desiredLogEnabled == true {
NSLog("AliyunHttpDns(iOS): build success, appId=\(appId ?? "")")
}
result(appId != nil && primaryServiceHost != nil)
if let en = desiredPreResolveAfterNetworkChanged {
svc.setPreResolveAfterNetworkChanged(en)
}
if let ipRankingMap = desiredIPRankingMap, !ipRankingMap.isEmpty {
svc.setIPRankingDatasource(ipRankingMap)
}
NSLog("AliyunHttpDns: build completed accountId=\(accountId)")
result(true)
// Dart: resolveHostSyncNonBlocking(hostname, ipType, sdnsParams?, cacheKey?)
case "resolveHostSyncNonBlocking":
guard let args = call.arguments as? [String: Any], let host = args["hostname"] as? String else {
guard let args = call.arguments as? [String: Any],
let hostname = args["hostname"] as? String else {
result(["ipv4": [], "ipv6": []])
return
}
let ipTypeStr = (args["ipType"] as? String) ?? "auto"
let sdnsParams = args["sdnsParams"] as? [String: String]
let cacheKey = args["cacheKey"] as? String
let type: HttpdnsQueryIPType
switch ipTypeStr.lowercased() {
case "ipv4", "v4": type = .ipv4
case "ipv6", "v6": type = .ipv6
case "both", "64": type = .both
default: type = .auto
let ipType = ((args["ipType"] as? String) ?? "auto").lowercased()
resolveHost(hostname: hostname, ipType: ipType) { payload in
result(payload)
}
let svc = HttpDnsService.sharedInstance()
var v4: [String] = []
var v6: [String] = []
if let params = sdnsParams, let key = cacheKey, let r = svc.resolveHostSyncNonBlocking(host, by: type, withSdnsParams: params, sdnsCacheKey: key) {
if r.hasIpv4Address() { v4 = r.ips }
if r.hasIpv6Address() { v6 = r.ipv6s }
} else if let r = svc.resolveHostSyncNonBlocking(host, by: type) {
if r.hasIpv4Address() { v4 = r.ips }
if r.hasIpv6Address() { v6 = r.ipv6s }
case "resolveHostV1":
guard let args = call.arguments as? [String: Any],
let hostname = args["hostname"] as? String else {
result(["ipv4": [], "ipv6": [], "ttl": 0])
return
}
let qtype = ((args["qtype"] as? String) ?? "A").uppercased()
let cip = (args["cip"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
resolveSingle(hostname: hostname, qtype: qtype, cip: cip) { records, ttl in
if qtype == "AAAA" {
result(["ipv4": [], "ipv6": records, "ttl": ttl])
} else {
result(["ipv4": records, "ipv6": [], "ttl": ttl])
}
}
result(["ipv4": v4, "ipv6": v6])
// Legacy methods removed: preResolve / clearCache
default:
result(FlutterMethodNotImplemented)
}
}
private func resolveHost(hostname: String, ipType: String, completion: @escaping ([String: [String]]) -> Void) {
let qtypes: [String]
switch ipType {
case "ipv6", "v6":
qtypes = ["AAAA"]
case "both", "64":
qtypes = ["A", "AAAA"]
default:
qtypes = ["A"]
}
let group = DispatchGroup()
let lock = NSLock()
var ipv4: [String] = []
var ipv6: [String] = []
for qtype in qtypes {
group.enter()
resolveSingle(hostname: hostname, qtype: qtype, cip: nil) { records, _ in
lock.lock()
if qtype == "AAAA" {
ipv6.append(contentsOf: records)
} else {
ipv4.append(contentsOf: records)
}
lock.unlock()
group.leave()
}
}
group.notify(queue: .main) {
completion([
"ipv4": Array(Set(ipv4)),
"ipv6": Array(Set(ipv6))
])
}
}
private func resolveSingle(hostname: String, qtype: String, cip: String?, completion: @escaping ([String], Int) -> Void) {
guard let appId = appId,
let primary = primaryServiceHost else {
completion([], 0)
return
}
var hosts: [String] = [primary]
if let backup = backupServiceHost, !backup.isEmpty, backup != primary {
hosts.append(backup)
}
func attempt(_ index: Int) {
if index >= hosts.count {
completion([], 0)
return
}
let serviceHost = hosts[index]
var components = URLComponents()
components.scheme = "https"
components.host = serviceHost
components.port = servicePort
components.path = "/resolve"
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "appId", value: appId),
URLQueryItem(name: "dn", value: hostname),
URLQueryItem(name: "qtype", value: qtype),
URLQueryItem(name: "sdk_version", value: "flutter-ios-1.0.0"),
URLQueryItem(name: "os", value: "ios"),
URLQueryItem(name: "sid", value: sessionId)
]
if let secret = secretKey, !secret.isEmpty {
let exp = String(Int(Date().timeIntervalSince1970) + 600)
let nonce = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let signRaw = "\(appId)|\(hostname.lowercased())|\(qtype.uppercased())|\(exp)|\(nonce)"
let sign = hmacSHA256Hex(message: signRaw, secret: secret)
queryItems.append(URLQueryItem(name: "exp", value: exp))
queryItems.append(URLQueryItem(name: "nonce", value: nonce))
queryItems.append(URLQueryItem(name: "sign", value: sign))
}
if let cip = cip, !cip.isEmpty {
queryItems.append(URLQueryItem(name: "cip", value: cip))
}
components.queryItems = queryItems
guard let url = components.url else {
completion([], 0)
return
}
var req = URLRequest(url: url)
req.httpMethod = "GET"
req.timeoutInterval = desiredConnectTimeoutSeconds + desiredReadTimeoutSeconds
req.setValue(serviceHost, forHTTPHeaderField: "Host")
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = desiredConnectTimeoutSeconds + desiredReadTimeoutSeconds
config.timeoutIntervalForResource = desiredConnectTimeoutSeconds + desiredReadTimeoutSeconds
let session = URLSession(configuration: config)
session.dataTask(with: req) { [weak self] data, _, error in
defer { session.finishTasksAndInvalidate() }
if let error = error {
if self?.desiredLogEnabled == true {
NSLog("AliyunHttpDns(iOS): resolve request failed host=\(serviceHost), err=\(error.localizedDescription)")
}
attempt(index + 1)
return
}
guard let data = data else {
attempt(index + 1)
return
}
let parsedIPs = Self.extractIPsFromResolveResponse(data: data, qtype: qtype)
if parsedIPs.isEmpty {
attempt(index + 1)
return
}
let ttl = Self.extractTTLFromResolveResponse(data: data)
completion(parsedIPs, ttl)
}.resume()
}
attempt(0)
}
private static func extractIPsFromResolveResponse(data: Data, qtype: String) -> [String] {
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
isResolveSuccessCode(obj["code"]),
let dataObj = obj["data"] as? [String: Any],
let records = dataObj["records"] as? [[String: Any]] else {
return []
}
var ips: [String] = []
for row in records {
let type = ((row["type"] as? String) ?? "").uppercased()
if type != qtype.uppercased() {
continue
}
if let ip = row["ip"] as? String, !ip.isEmpty {
ips.append(ip)
}
}
return ips
}
private static func extractTTLFromResolveResponse(data: Data) -> Int {
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataObj = obj["data"] as? [String: Any],
let ttlValue = dataObj["ttl"] else {
return 0
}
if let ttl = ttlValue as? Int {
return ttl
}
if let ttlString = ttlValue as? String, let ttl = Int(ttlString) {
return ttl
}
return 0
}
private static func isResolveSuccessCode(_ codeValue: Any?) -> Bool {
if let code = codeValue as? String {
let normalized = code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
return normalized == "SUCCESS" || normalized == "0"
}
if let code = codeValue as? Int {
return code == 0
}
if let code = codeValue as? NSNumber {
return code.intValue == 0
}
return false
}
private func hmacSHA256Hex(message: String, secret: String) -> String {
guard let keyData = secret.data(using: .utf8),
let messageData = message.data(using: .utf8) else {
return ""
}
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
keyData.withUnsafeBytes { keyBytes in
messageData.withUnsafeBytes { msgBytes in
CCHmac(
CCHmacAlgorithm(kCCHmacAlgSHA256),
keyBytes.baseAddress,
keyData.count,
msgBytes.baseAddress,
messageData.count,
&digest
)
}
}
return digest.map { String(format: "%02x", $0) }.joined()
}
}

View File

@@ -3,7 +3,7 @@
#
Pod::Spec.new do |s|
s.name = 'aliyun_httpdns'
s.version = '1.0.2'
s.version = '1.0.0'
s.summary = 'aliyun httpdns flutter plugin'
s.description = <<-DESC
aliyun httpdns flutter plugin.
@@ -16,7 +16,6 @@ DESC
s.public_header_files = 'Classes/**/*.h'
s.static_framework = true
s.dependency 'Flutter'
s.dependency 'AlicloudHTTPDNS', '3.4.0'
s.platform = :ios, '10.0'
# Flutter.framework does not contain a i386 slice.

View File

@@ -1,87 +1,89 @@
import 'dart:async';
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/services.dart';
class AliyunHttpdns {
static const MethodChannel _channel = MethodChannel('aliyun_httpdns');
/// 1) 初始化:使用 accountId/secretKey/aesSecretKey
/// New API only:
/// appId + primary/backup service host + optional sign secret.
static Future<bool> init({
required int accountId,
required String appId,
required String primaryServiceHost,
String? backupServiceHost,
int servicePort = 443,
String? secretKey,
String? aesSecretKey,
}) async {
final ok =
await _channel.invokeMethod<bool>('initialize', <String, dynamic>{
'accountId': accountId,
if (secretKey != null) 'secretKey': secretKey,
if (aesSecretKey != null) 'aesSecretKey': aesSecretKey,
});
final String normalizedAppId = appId.trim();
final String normalizedPrimary = primaryServiceHost.trim();
if (normalizedAppId.isEmpty || normalizedPrimary.isEmpty) {
return false;
}
final Map<String, dynamic> args = <String, dynamic>{
'appId': normalizedAppId,
'primaryServiceHost': normalizedPrimary,
if (backupServiceHost != null && backupServiceHost.trim().isNotEmpty)
'backupServiceHost': backupServiceHost.trim(),
if (servicePort > 0) 'servicePort': servicePort,
if (secretKey != null && secretKey.isNotEmpty) 'secretKey': secretKey,
};
final bool? ok = await _channel.invokeMethod<bool>('initialize', args);
return ok ?? false;
}
/// 构建底层 service只有在调用了 initialize / 一系列 setXxx 后,
/// 调用本方法才会真正创建底层实例并应用配置
static Future<bool> build() async {
final ok = await _channel.invokeMethod<bool>('build');
final bool? ok = await _channel.invokeMethod<bool>('build');
return ok ?? false;
}
/// 2) 设置日志开关
static Future<void> setLogEnabled(bool enabled) async {
await _channel.invokeMethod<void>('setLogEnabled', <String, dynamic>{
'enabled': enabled,
});
await _channel.invokeMethod<void>('setLogEnabled', <String, dynamic>{'enabled': enabled});
}
/// 3) 设置持久化缓存
static Future<void> setPersistentCacheIPEnabled(bool enabled,
{int? discardExpiredAfterSeconds}) async {
await _channel
.invokeMethod<void>('setPersistentCacheIPEnabled', <String, dynamic>{
await _channel.invokeMethod<void>('setPersistentCacheIPEnabled', <String, dynamic>{
'enabled': enabled,
if (discardExpiredAfterSeconds != null)
'discardExpiredAfterSeconds': discardExpiredAfterSeconds,
});
}
/// 4) 是否允许复用过期 IP
static Future<void> setReuseExpiredIPEnabled(bool enabled) async {
await _channel
.invokeMethod<void>('setReuseExpiredIPEnabled', <String, dynamic>{
'enabled': enabled,
});
.invokeMethod<void>('setReuseExpiredIPEnabled', <String, dynamic>{'enabled': enabled});
}
/// 设置是否使用 HTTPS 解析链路,避免明文流量被系统拦截
static Future<void> setHttpsRequestEnabled(bool enabled) async {
await _channel
.invokeMethod<void>('setHttpsRequestEnabled', <String, dynamic>{
'enabled': enabled,
});
.invokeMethod<void>('setHttpsRequestEnabled', <String, dynamic>{'enabled': enabled});
}
/// 5) 伪异步解析:返回 IPv4/IPv6 数组
/// 返回格式:{"ipv4": `List<String>`, "ipv6": `List<String>`}
static Future<Map<String, List<String>>> resolveHostSyncNonBlocking(
String hostname, {
String ipType = 'auto', // auto/ipv4/ipv6/both
Map<String, String>? sdnsParams,
String? cacheKey,
}) async {
final Map<dynamic, dynamic>? res = await _channel
.invokeMethod('resolveHostSyncNonBlocking', <String, dynamic>{
final Map<dynamic, dynamic>? res =
await _channel.invokeMethod<Map<dynamic, dynamic>>('resolveHostSyncNonBlocking',
<String, dynamic>{
'hostname': hostname,
'ipType': ipType,
if (sdnsParams != null) 'sdnsParams': sdnsParams,
if (cacheKey != null) 'cacheKey': cacheKey,
});
final Map<String, List<String>> out = {
final Map<String, List<String>> out = <String, List<String>>{
'ipv4': <String>[],
'ipv6': <String>[],
};
if (res == null) return out;
final v4 = res['ipv4'];
final v6 = res['ipv6'];
if (res == null) {
return out;
}
final dynamic v4 = res['ipv4'];
final dynamic v6 = res['ipv6'];
if (v4 is List) {
out['ipv4'] = v4.map((e) => e.toString()).toList();
}
@@ -91,51 +93,190 @@ class AliyunHttpdns {
return out;
}
// 解析域名,返回 A/AAAA 记录等(保留旧接口以兼容,未在本任务使用)
static Future<Map<String, dynamic>?> resolve(String hostname,
{Map<String, dynamic>? options}) async {
final res = await _channel.invokeMethod<Map<dynamic, dynamic>>('resolve', {
/// V1 resolve API:
/// qtype supports A / AAAA, optional cip for route simulation.
static Future<Map<String, dynamic>> resolveHost(
String hostname, {
String qtype = 'A',
String? cip,
}) async {
final Map<dynamic, dynamic>? res =
await _channel.invokeMethod<Map<dynamic, dynamic>>('resolveHostV1', <String, dynamic>{
'hostname': hostname,
if (options != null) 'options': options,
'qtype': qtype,
if (cip != null && cip.trim().isNotEmpty) 'cip': cip.trim(),
});
return res?.map((key, value) => MapEntry(key.toString(), value));
final Map<String, dynamic> out = <String, dynamic>{
'ipv4': <String>[],
'ipv6': <String>[],
'ttl': 0,
};
if (res == null) {
return out;
}
final dynamic v4 = res['ipv4'];
final dynamic v6 = res['ipv6'];
if (v4 is List) {
out['ipv4'] = v4.map((e) => e.toString()).toList();
}
if (v6 is List) {
out['ipv6'] = v6.map((e) => e.toString()).toList();
}
final dynamic ttl = res['ttl'];
if (ttl is int) {
out['ttl'] = ttl;
}
return out;
}
// 1) setPreResolveHosts: 传入 host 列表native 侧调用 SDK 预解析
static Future<void> setPreResolveHosts(List<String> hosts,
{String ipType = 'auto'}) async {
static Future<void> setPreResolveHosts(List<String> hosts, {String ipType = 'auto'}) async {
await _channel.invokeMethod<void>('setPreResolveHosts', <String, dynamic>{
'hosts': hosts,
'ipType': ipType,
});
}
// 2) setLogEnabled: 已有,同步保留(在此文件顶部已有 setLogEnabled 实现)
// 3) setPreResolveAfterNetworkChanged: 是否在网络切换时自动刷新解析
static Future<void> setPreResolveAfterNetworkChanged(bool enabled) async {
await _channel.invokeMethod<void>(
'setPreResolveAfterNetworkChanged', <String, dynamic>{
await _channel.invokeMethod<void>('setPreResolveAfterNetworkChanged', <String, dynamic>{
'enabled': enabled,
});
}
// 4) getSessionId: 获取会话 id
static Future<String?> getSessionId() async {
final sid = await _channel.invokeMethod<String>('getSessionId');
return sid;
static Future<void> setIPRankingList(Map<String, int> hostPortMap) async {
await _channel
.invokeMethod<void>('setIPRankingList', <String, dynamic>{'hostPortMap': hostPortMap});
}
static Future<String?> getSessionId() async {
return _channel.invokeMethod<String>('getSessionId');
}
// 5) cleanAllHostCache: 清除所有缓存
static Future<void> cleanAllHostCache() async {
await _channel.invokeMethod<void>('cleanAllHostCache');
}
/// 设置 IP 优选列表
/// [hostPortMap] 域名和端口的映射,例如:{'www.aliyun.com': 443}
static Future<void> setIPRankingList(Map<String, int> hostPortMap) async {
await _channel.invokeMethod<void>('setIPRankingList', <String, dynamic>{
'hostPortMap': hostPortMap,
});
static AliyunHttpdnsHttpAdapter createHttpAdapter({
AliyunHttpdnsAdapterOptions options = const AliyunHttpdnsAdapterOptions(),
}) {
return AliyunHttpdnsHttpAdapter._(options);
}
}
class AliyunHttpdnsAdapterOptions {
final String ipType;
final int connectTimeoutMs;
final int readTimeoutMs;
final bool allowInsecureCertificatesForDebugOnly;
const AliyunHttpdnsAdapterOptions({
this.ipType = 'auto',
this.connectTimeoutMs = 3000,
this.readTimeoutMs = 5000,
this.allowInsecureCertificatesForDebugOnly = false,
});
}
class AliyunHttpdnsRequestResult {
final int statusCode;
final Map<String, List<String>> headers;
final Uint8List body;
final String usedIp;
const AliyunHttpdnsRequestResult({
required this.statusCode,
required this.headers,
required this.body,
required this.usedIp,
});
}
class AliyunHttpdnsHttpAdapter {
final AliyunHttpdnsAdapterOptions _options;
AliyunHttpdnsHttpAdapter._(this._options);
/// Fixed behavior:
/// 1) resolve host by HTTPDNS
/// 2) connect to IP:443
/// 3) keep HTTP Host as original domain
/// 4) no fallback to domain connect
Future<AliyunHttpdnsRequestResult> request(
Uri uri, {
String method = 'GET',
Map<String, String>? headers,
List<int>? body,
}) async {
if (uri.host.isEmpty) {
throw const HttpException('HOST_ROUTE_REJECTED: host is empty');
}
if (uri.scheme.toLowerCase() != 'https') {
throw const HttpException('TLS_EMPTY_SNI_FAILED: only https is supported');
}
final Map<String, List<String>> resolved = await AliyunHttpdns.resolveHostSyncNonBlocking(
uri.host,
ipType: _options.ipType,
);
final List<String> ips = <String>[
...?resolved['ipv4'],
...?resolved['ipv6'],
];
if (ips.isEmpty) {
throw const HttpException('NO_IP_AVAILABLE: HTTPDNS returned empty ip list');
}
Object? lastError;
for (final String ip in ips) {
final HttpClient client = HttpClient();
client.connectionTimeout = Duration(milliseconds: _options.connectTimeoutMs);
if (_options.allowInsecureCertificatesForDebugOnly) {
client.badCertificateCallback = (_, __, ___) => true;
}
try {
final Uri target = uri.replace(
host: ip,
port: uri.hasPort ? uri.port : 443,
);
final HttpClientRequest req = await client
.openUrl(method, target)
.timeout(Duration(milliseconds: _options.connectTimeoutMs));
req.headers.host = uri.host;
headers?.forEach((String key, String value) {
if (key.toLowerCase() == 'host') {
return;
}
req.headers.set(key, value);
});
if (body != null && body.isNotEmpty) {
req.add(body);
}
final HttpClientResponse resp =
await req.close().timeout(Duration(milliseconds: _options.readTimeoutMs));
final List<int> payload =
await resp.fold<List<int>>(<int>[], (List<int> previous, List<int> element) {
previous.addAll(element);
return previous;
});
final Map<String, List<String>> responseHeaders = <String, List<String>>{};
resp.headers.forEach((String name, List<String> values) {
responseHeaders[name] = values;
});
return AliyunHttpdnsRequestResult(
statusCode: resp.statusCode,
headers: responseHeaders,
body: Uint8List.fromList(payload),
usedIp: ip,
);
} catch (e) {
lastError = e;
} finally {
client.close(force: true);
}
}
throw HttpException('TLS_EMPTY_SNI_FAILED: all ip connect attempts failed, error=$lastError');
}
}

View File

@@ -1,6 +1,6 @@
name: aliyun_httpdns
description: "Aliyun HTTPDNS Flutter plugin."
version: 1.0.2
version: 1.0.0
homepage: https://help.aliyun.com/document_detail/2584339.html
environment:

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = "AlicloudHTTPDNS"
s.version = "3.4.1"
s.version = "1.0.0"
s.summary = "Aliyun Mobile Service HTTPDNS iOS SDK (source distribution)."
s.description = <<-DESC
HTTPDNS iOS SDK 源码分发版本,提供通过 HTTP(S) 进行域名解析、
@@ -27,6 +27,7 @@ Pod::Spec.new do |s|
s.public_header_files = [
"AlicloudHttpDNS/AlicloudHttpDNS.h",
"AlicloudHttpDNS/HttpdnsService.h",
"AlicloudHttpDNS/HttpdnsEdgeService.h",
"AlicloudHttpDNS/Model/HttpdnsResult.h",
"AlicloudHttpDNS/Model/HttpdnsRequest.h",
"AlicloudHttpDNS/Log/HttpdnsLog.h",

View File

@@ -24,6 +24,7 @@
#import <AlicloudHTTPDNS/HttpdnsService.h>
#import <AlicloudHTTPDNS/HttpdnsRequest.h>
#import <AlicloudHTTPDNS/HttpDnsResult.h>
#import <AlicloudHTTPDNS/HttpdnsEdgeService.h>
#import <AlicloudHTTPDNS/HttpdnsLoggerProtocol.h>
#import <AlicloudHTTPDNS/HttpdnsDegradationDelegate.h>
#import <AlicloudHTTPDNS/HttpdnsIpStackDetector.h>

View File

@@ -9,7 +9,7 @@
#ifndef PublicConstant_h
#define PublicConstant_h
static NSString *const HTTPDNS_IOS_SDK_VERSION = @"3.4.1";
static NSString *const HTTPDNS_IOS_SDK_VERSION = @"1.0.0";
#define ALICLOUD_HTTPDNS_DEFAULT_REGION_KEY @"cn"
#define ALICLOUD_HTTPDNS_HONGKONG_REGION_KEY @"hk"

View File

@@ -0,0 +1,36 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface HttpdnsEdgeResolveResult : NSObject
@property (nonatomic, copy) NSString *requestId;
@property (nonatomic, copy) NSArray<NSString *> *ipv4s;
@property (nonatomic, copy) NSArray<NSString *> *ipv6s;
@property (nonatomic, assign) NSInteger ttl;
@end
@interface HttpdnsEdgeService : NSObject
- (instancetype)initWithAppId:(NSString *)appId
primaryServiceHost:(NSString *)primaryServiceHost
backupServiceHost:(nullable NSString *)backupServiceHost
servicePort:(NSInteger)servicePort
signSecret:(nullable NSString *)signSecret;
- (void)resolveHost:(NSString *)host
queryType:(NSString *)queryType
completion:(void (^)(HttpdnsEdgeResolveResult *_Nullable result, NSError *_Nullable error))completion;
/// Connect by IP + HTTPS and keep Host header as business domain.
/// This path will not fallback to domain connect.
- (void)requestURL:(NSURL *)url
method:(NSString *)method
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
body:(nullable NSData *)body
completion:(void (^)(NSData *_Nullable data, NSHTTPURLResponse *_Nullable response, NSError *_Nullable error))completion;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,277 @@
#import "HttpdnsEdgeService.h"
#import <CommonCrypto/CommonCrypto.h>
static NSString * const kHttpdnsEdgeErrorDomain = @"com.goeedge.httpdns.edge";
@implementation HttpdnsEdgeResolveResult
@end
@interface HttpdnsEdgeService ()
@property (nonatomic, copy) NSString *appId;
@property (nonatomic, copy) NSString *primaryServiceHost;
@property (nonatomic, copy) NSString *backupServiceHost;
@property (nonatomic, assign) NSInteger servicePort;
@property (nonatomic, copy) NSString *signSecret;
@property (nonatomic, copy) NSString *sessionId;
@end
@implementation HttpdnsEdgeService
- (instancetype)initWithAppId:(NSString *)appId
primaryServiceHost:(NSString *)primaryServiceHost
backupServiceHost:(NSString *)backupServiceHost
servicePort:(NSInteger)servicePort
signSecret:(NSString *)signSecret {
if (self = [super init]) {
_appId = [appId copy];
_primaryServiceHost = [primaryServiceHost copy];
_backupServiceHost = backupServiceHost.length > 0 ? [backupServiceHost copy] : @"";
_servicePort = servicePort > 0 ? servicePort : 443;
_signSecret = signSecret.length > 0 ? [signSecret copy] : @"";
_sessionId = [[[NSUUID UUID].UUIDString stringByReplacingOccurrencesOfString:@"-" withString:@""] copy];
}
return self;
}
- (void)resolveHost:(NSString *)host
queryType:(NSString *)queryType
completion:(void (^)(HttpdnsEdgeResolveResult *_Nullable, NSError *_Nullable))completion {
if (host.length == 0 || self.appId.length == 0 || self.primaryServiceHost.length == 0) {
NSError *error = [NSError errorWithDomain:kHttpdnsEdgeErrorDomain
code:1001
userInfo:@{NSLocalizedDescriptionKey: @"invalid init config or host"}];
completion(nil, error);
return;
}
NSString *qtype = queryType.length > 0 ? queryType.uppercaseString : @"A";
NSArray<NSString *> *hosts = self.backupServiceHost.length > 0
? @[self.primaryServiceHost, self.backupServiceHost]
: @[self.primaryServiceHost];
[self requestResolveHosts:hosts index:0 host:host qtype:qtype completion:completion];
}
- (void)requestURL:(NSURL *)url
method:(NSString *)method
headers:(NSDictionary<NSString *,NSString *> *)headers
body:(NSData *)body
completion:(void (^)(NSData *_Nullable, NSHTTPURLResponse *_Nullable, NSError *_Nullable))completion {
NSString *originHost = url.host ?: @"";
if (originHost.length == 0) {
NSError *error = [NSError errorWithDomain:kHttpdnsEdgeErrorDomain
code:2001
userInfo:@{NSLocalizedDescriptionKey: @"invalid request host"}];
completion(nil, nil, error);
return;
}
[self resolveHost:originHost queryType:@"A" completion:^(HttpdnsEdgeResolveResult * _Nullable result, NSError * _Nullable error) {
if (error != nil || result.ipv4s.count == 0) {
completion(nil, nil, error ?: [NSError errorWithDomain:kHttpdnsEdgeErrorDomain
code:2002
userInfo:@{NSLocalizedDescriptionKey: @"NO_IP_AVAILABLE"}]);
return;
}
[self requestByIPList:result.ipv4s
index:0
url:url
method:method
headers:headers
body:body
completion:completion];
}];
}
- (void)requestByIPList:(NSArray<NSString *> *)ips
index:(NSInteger)index
url:(NSURL *)url
method:(NSString *)method
headers:(NSDictionary<NSString *, NSString *> *)headers
body:(NSData *)body
completion:(void (^)(NSData *_Nullable, NSHTTPURLResponse *_Nullable, NSError *_Nullable))completion {
if (index >= ips.count) {
NSError *error = [NSError errorWithDomain:kHttpdnsEdgeErrorDomain
code:2003
userInfo:@{NSLocalizedDescriptionKey: @"TLS_EMPTY_SNI_FAILED"}];
completion(nil, nil, error);
return;
}
NSString *ip = ips[index];
NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
components.host = ip;
if (components.port == nil) {
components.port = @(443);
}
NSURL *targetURL = components.URL;
if (targetURL == nil) {
[self requestByIPList:ips index:index + 1 url:url method:method headers:headers body:body completion:completion];
return;
}
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:targetURL
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
timeoutInterval:8];
request.HTTPMethod = method.length > 0 ? method : @"GET";
if (body.length > 0) {
request.HTTPBody = body;
}
[request setValue:url.host forHTTPHeaderField:@"Host"];
[headers enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSString * _Nonnull obj, BOOL * _Nonnull stop) {
if ([key.lowercaseString isEqualToString:@"host"]) {
return;
}
[request setValue:obj forHTTPHeaderField:key];
}];
NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration];
config.timeoutIntervalForRequest = 8;
config.timeoutIntervalForResource = 8;
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
[[session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
[session finishTasksAndInvalidate];
if (error != nil) {
[self requestByIPList:ips index:index + 1 url:url method:method headers:headers body:body completion:completion];
return;
}
completion(data, (NSHTTPURLResponse *)response, nil);
}] resume];
}
- (void)requestResolveHosts:(NSArray<NSString *> *)hosts
index:(NSInteger)index
host:(NSString *)host
qtype:(NSString *)qtype
completion:(void (^)(HttpdnsEdgeResolveResult *_Nullable result, NSError *_Nullable error))completion {
if (index >= hosts.count) {
NSError *error = [NSError errorWithDomain:kHttpdnsEdgeErrorDomain
code:1002
userInfo:@{NSLocalizedDescriptionKey: @"resolve failed on all service hosts"}];
completion(nil, error);
return;
}
NSString *serviceHost = hosts[index];
NSURLComponents *components = [NSURLComponents new];
components.scheme = @"https";
components.host = serviceHost;
components.port = @(self.servicePort);
components.path = @"/resolve";
NSMutableArray<NSURLQueryItem *> *items = [NSMutableArray arrayWithArray:@[
[NSURLQueryItem queryItemWithName:@"appId" value:self.appId],
[NSURLQueryItem queryItemWithName:@"dn" value:host],
[NSURLQueryItem queryItemWithName:@"qtype" value:qtype],
[NSURLQueryItem queryItemWithName:@"sid" value:self.sessionId],
[NSURLQueryItem queryItemWithName:@"sdk_version" value:@"ios-native-1.0.0"],
[NSURLQueryItem queryItemWithName:@"os" value:@"ios"],
]];
if (self.signSecret.length > 0) {
NSString *exp = [NSString stringWithFormat:@"%ld", (long)([[NSDate date] timeIntervalSince1970] + 600)];
NSString *nonce = [[NSUUID UUID].UUIDString stringByReplacingOccurrencesOfString:@"-" withString:@""];
NSString *raw = [NSString stringWithFormat:@"%@|%@|%@|%@|%@",
self.appId,
host.lowercaseString,
qtype.uppercaseString,
exp,
nonce];
NSString *sign = [self hmacSha256Hex:raw secret:self.signSecret];
[items addObject:[NSURLQueryItem queryItemWithName:@"exp" value:exp]];
[items addObject:[NSURLQueryItem queryItemWithName:@"nonce" value:nonce]];
[items addObject:[NSURLQueryItem queryItemWithName:@"sign" value:sign]];
}
components.queryItems = items;
NSURL *url = components.URL;
if (url == nil) {
[self requestResolveHosts:hosts index:index + 1 host:host qtype:qtype completion:completion];
return;
}
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
timeoutInterval:6];
request.HTTPMethod = @"GET";
NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration];
config.timeoutIntervalForRequest = 6;
config.timeoutIntervalForResource = 6;
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
[[session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
[session finishTasksAndInvalidate];
if (error != nil || data.length == 0) {
[self requestResolveHosts:hosts index:index + 1 host:host qtype:qtype completion:completion];
return;
}
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (![json isKindOfClass:NSDictionary.class]) {
[self requestResolveHosts:hosts index:index + 1 host:host qtype:qtype completion:completion];
return;
}
NSString *code = [NSString stringWithFormat:@"%@", json[@"code"] ?: @""];
if (![code.uppercaseString isEqualToString:@"SUCCESS"]) {
[self requestResolveHosts:hosts index:index + 1 host:host qtype:qtype completion:completion];
return;
}
NSDictionary *dataJSON = [json[@"data"] isKindOfClass:NSDictionary.class] ? json[@"data"] : @{};
NSArray *records = [dataJSON[@"records"] isKindOfClass:NSArray.class] ? dataJSON[@"records"] : @[];
NSMutableArray<NSString *> *ipv4 = [NSMutableArray array];
NSMutableArray<NSString *> *ipv6 = [NSMutableArray array];
for (NSDictionary *row in records) {
NSString *type = [NSString stringWithFormat:@"%@", row[@"type"] ?: @""];
NSString *ip = [NSString stringWithFormat:@"%@", row[@"ip"] ?: @""];
if (ip.length == 0) {
continue;
}
if ([type.uppercaseString isEqualToString:@"AAAA"]) {
[ipv6 addObject:ip];
} else {
[ipv4 addObject:ip];
}
}
HttpdnsEdgeResolveResult *result = [HttpdnsEdgeResolveResult new];
result.requestId = [NSString stringWithFormat:@"%@", json[@"requestId"] ?: @""];
result.ipv4s = ipv4;
result.ipv6s = ipv6;
result.ttl = [dataJSON[@"ttl"] integerValue];
completion(result, nil);
}] resume];
}
- (NSString *)hmacSha256Hex:(NSString *)data secret:(NSString *)secret {
NSData *keyData = [secret dataUsingEncoding:NSUTF8StringEncoding];
NSData *messageData = [data dataUsingEncoding:NSUTF8StringEncoding];
unsigned char cHMAC[CC_SHA256_DIGEST_LENGTH];
CCHmac(kCCHmacAlgSHA256,
keyData.bytes,
keyData.length,
messageData.bytes,
messageData.length,
cHMAC);
NSMutableString *result = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) {
[result appendFormat:@"%02x", cHMAC[i]];
}
return result;
}
@end

View File

@@ -1,73 +1,58 @@
# Alicloud HTTPDNS iOS SDK
# HTTPDNS iOS SDK (SNI Hidden v1.0.0)
面向 iOS 的 HTTP/HTTPS DNS 解析 SDK提供鉴权与可选 AES 加密、IPv4/IPv6 双栈解析、缓存与调度、预解析等能力。最低支持 iOS 12.0。
## 1. Init
## 功能特性
- 鉴权请求与可选 AES 传输加密
- IPv4/IPv6 双栈解析,支持自动/同时解析
- 内存 + 持久化缓存与 TTL 控制,可选择复用过期 IP
- 预解析、区域路由、网络切换自动刷新
- 可定制日志回调与会话追踪 `sessionId`
## 安装CocoaPods
`Podfile` 中添加:
```ruby
platform :ios, '12.0'
target 'YourApp' do
pod 'AlicloudHTTPDNS', '~> 3.4.1'
end
```
执行 `pod install` 安装依赖。
## 快速开始
ObjectiveC
```objc
#import <AlicloudHttpDNS/AlicloudHttpDNS.h>
#import <AlicloudHTTPDNS/AlicloudHTTPDNS.h>
// 使用鉴权初始化(单例);密钥请勿硬编码到仓库
HttpDnsService *service = [[HttpDnsService alloc] initWithAccountID:1000000
secretKey:@"<YOUR_SECRET>"];
[service setPersistentCacheIPEnabled:YES];
[service setPreResolveHosts:@[@"www.aliyun.com"] byIPType:HttpdnsQueryIPTypeAuto];
// 同步解析(会根据网络自动选择 v4/v6
HttpdnsResult *r = [service resolveHostSync:@"www.aliyun.com" byIpType:HttpdnsQueryIPTypeAuto];
NSLog(@"IPv4: %@", r.ips);
HttpdnsEdgeService *service = [[HttpdnsEdgeService alloc]
initWithAppId:@"app1f1ndpo9"
primaryServiceHost:@"httpdns-a.example.com"
backupServiceHost:@"httpdns-b.example.com"
servicePort:443
signSecret:@"your-sign-secret"]; // optional if sign is enabled
```
Swift
```swift
import AlicloudHttpDNS
## 2. Resolve
_ = HttpDnsService(accountID: 1000000, secretKey: "<YOUR_SECRET>")
let svc = HttpDnsService.sharedInstance()
let res = svc?.resolveHostSync("www.aliyun.com", byIpType: .auto)
print(res?.ips ?? [])
```objc
[service resolveHost:@"api.business.com" queryType:@"A" completion:^(HttpdnsEdgeResolveResult * _Nullable result, NSError * _Nullable error) {
if (error != nil) {
return;
}
NSLog(@"requestId=%@ ipv4=%@ ipv6=%@ ttl=%ld", result.requestId, result.ipv4s, result.ipv6s, (long)result.ttl);
}];
```
提示
- 启动时通过 `setPreResolveHosts(_:byIPType:)` 预热热点域名。
- 如需在刷新期间容忍 TTL 过期,可开启 `setReuseExpiredIPEnabled:YES`
- 使用 `getSessionId()` 并与选用 IP 一同记录,便于排障。
## 3. Official Request Adapter (IP + Host)
## 源码构建
执行 `./build_xc_framework.sh` 生成 XCFramework。脚本会从 `gitlab.alibaba-inc.com` 克隆内部构建工具;外部环境建议优先使用 CocoaPods 引入。
```objc
NSURL *url = [NSURL URLWithString:@"https://api.business.com/v1/ping"];
[service requestURL:url method:@"GET" headers:@{@"Accept": @"application/json"} body:nil completion:^(NSData * _Nullable data, NSHTTPURLResponse * _Nullable response, NSError * _Nullable error) {
if (error != nil) {
return;
}
NSLog(@"status=%ld", (long)response.statusCode);
}];
```
## 测试
- 在 Xcode 使用 Scheme `AlicloudHttpDNSTests` 运行。
- 含 OCMock 的用例批量执行可能出现内存问题,请单个运行。
- 非 Mock 用例使用预置参数AccountID `1000000`,测试域名 `*.onlyforhttpdnstest.run.place`(每年需续期)。
Behavior is fixed:
- Resolve by `/resolve`.
- Connect to resolved IP over HTTPS.
- Keep `Host` header as business domain.
- No fallback to domain direct request.
## 依赖与链接
- iOS 12.0+;需链接 `CoreTelephony``SystemConfiguration`
- 额外库:`sqlite3.0``resolv``OTHER_LDFLAGS` 包含 `-ObjC -lz`
## 4. Public Errors
## 安全说明
- 切勿提交真实的 AccountID/SecretKey請通过本地安全配置或 CI 注入。
- 若担心设备时间偏差影响鉴权,可用 `setInternalAuthTimeBaseBySpecifyingCurrentTime:` 校正。
- `NO_IP_AVAILABLE`
- `TLS_EMPTY_SNI_FAILED`
- `HOST_ROUTE_REJECTED`
- `RESOLVE_SIGN_INVALID`
## Demo 与贡献
- 示例应用:`AlicloudHttpDNSTestDemo/`
- 贡献与提交流程请参见 `AGENTS.md`(提交信息与 PR 规范)。
## 5. Removed Public Params
Do not expose legacy public parameters:
- `accountId`
- `serviceDomain`
- `endpoint`
- `aesSecretKey`