支持阿里云定向只读权限检查并识别RAM资源拒绝
This commit is contained in:
parent
aa3b5f17a1
commit
eb44af1f42
@ -21,12 +21,20 @@ type dnsPermissionVerifier func(context.Context, string, map[string]string) (dns
|
||||
func newDNSCommand() *cobra.Command {
|
||||
command := &cobra.Command{Use: "dns", Short: "检查指定域名的阿里云 RAM 权限"}
|
||||
checker := dnsprovider.NewAliyunAuthorizationChecker()
|
||||
command.AddCommand(newDNSCheckCommand(checker.Verify))
|
||||
command.AddCommand(newDNSCheckCommand(checker.Verify), newDNSInspectCommand(checker.Inspect))
|
||||
return command
|
||||
}
|
||||
|
||||
func newDNSInspectCommand(inspect dnsPermissionVerifier) *cobra.Command {
|
||||
return newDNSPermissionCommand(inspect, true)
|
||||
}
|
||||
|
||||
// 权限检查不启动应用、续期调度或数据库,也不调用全局域名发现。
|
||||
func newDNSCheckCommand(verify dnsPermissionVerifier) *cobra.Command {
|
||||
return newDNSPermissionCommand(verify, false)
|
||||
}
|
||||
|
||||
func newDNSPermissionCommand(verify dnsPermissionVerifier, readOnly bool) *cobra.Command {
|
||||
var requestedDomain string
|
||||
var confirmWrite bool
|
||||
command := &cobra.Command{
|
||||
@ -38,7 +46,7 @@ func newDNSCheckCommand(verify dnsPermissionVerifier) *cobra.Command {
|
||||
if !domain.IsValidName(requestedDomain) {
|
||||
return errors.New("必须提供有效的待检查域名")
|
||||
}
|
||||
if !confirmWrite {
|
||||
if !readOnly && !confirmWrite {
|
||||
return errors.New("此检查会写入并删除专用临时 TXT 记录;确认已获目标域名测试授权后,请添加 --confirm-dns-write")
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
@ -72,14 +80,19 @@ func newDNSCheckCommand(verify dnsPermissionVerifier) *cobra.Command {
|
||||
if verifyErr != nil {
|
||||
return fmt.Errorf("域名权限检查未通过:%w", verifyErr)
|
||||
}
|
||||
if !report.Verified {
|
||||
if !readOnly && !report.Verified {
|
||||
return errors.New("域名权限检查未通过:未获得写入及清理均成功的证据")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
command.Flags().StringVar(&requestedDomain, "domain", "", "明确授权检查的单个域名,不枚举全账号域名")
|
||||
command.Flags().BoolVar(&confirmWrite, "confirm-dns-write", false, "确认允许专用临时 TXT 记录写入和精确清理")
|
||||
if readOnly {
|
||||
command.Use = "inspect"
|
||||
command.Short = "只读检查指定托管区,不写入 DNS、不证明可写"
|
||||
} else {
|
||||
command.Flags().BoolVar(&confirmWrite, "confirm-dns-write", false, "确认允许专用临时 TXT 记录写入和精确清理")
|
||||
}
|
||||
_ = command.MarkFlagRequired("domain")
|
||||
return command
|
||||
}
|
||||
|
||||
@ -30,6 +30,28 @@ func TestDNSCheckRequiresExplicitWriteConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSInspectDoesNotRequireWriteOrPromoteReadableToVerified(t *testing.T) {
|
||||
prepareDNSCheckConfig(t)
|
||||
command := newDNSInspectCommand(func(context.Context, string, map[string]string) (dnsprovider.AuthorizationReport, error) {
|
||||
return dnsprovider.AuthorizationReport{
|
||||
Provider: "aliyun", Domains: []dnsprovider.AuthorizationDomain{{Domain: "example.com", Listed: true}},
|
||||
}, nil
|
||||
})
|
||||
var output bytes.Buffer
|
||||
command.SetOut(&output)
|
||||
command.SetArgs([]string{"--domain", "example.com"})
|
||||
if err := command.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var report dnsprovider.AuthorizationReport
|
||||
if err := json.Unmarshal(output.Bytes(), &report); err != nil || report.Verified || report.Domains[0].Writable {
|
||||
t.Fatalf("只读成功不能升级为写权限通过:%s", output.String())
|
||||
}
|
||||
if command.Flag("confirm-dns-write") != nil {
|
||||
t.Fatal("只读命令不得提供写入开关")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSCheckReportsLiveResultWithoutPersistingConfiguration(t *testing.T) {
|
||||
for _, outcome := range []string{"allowed", "denied", "unknown", "unverified"} {
|
||||
t.Run(outcome, func(t *testing.T) {
|
||||
|
||||
@ -81,6 +81,35 @@ func (c *AliyunAuthorizationChecker) Discover(ctx context.Context, credentials m
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// Inspect 只查询显式指定托管区的读取权限,不逐级探测父域、不写入 DNS,也不证明可写。
|
||||
func (c *AliyunAuthorizationChecker) Inspect(ctx context.Context, requestedDomain string, credentials map[string]string) (AuthorizationReport, error) {
|
||||
requestedDomain = strings.TrimPrefix(normalizeAuthorizationDomain(requestedDomain), "*.")
|
||||
report := AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()}
|
||||
if !domain.IsValidName(requestedDomain) {
|
||||
return report, fmt.Errorf("待检查托管区格式无效:%s", requestedDomain)
|
||||
}
|
||||
report.Domains = []AuthorizationDomain{{Domain: requestedDomain, Status: "等待读取权限检查"}}
|
||||
dnsClient, err := newAliyunClient(credentials)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
request := new(client.DescribeDomainRecordsRequest).SetDomainName(requestedDomain).SetPageNumber(1).SetPageSize(1)
|
||||
response, err := client.DescribeDomainRecordsWithContext(ctx, dnsClient, request, &dara.RuntimeOptions{})
|
||||
if err != nil {
|
||||
report.Domains[0].Status = "读取未通过,可写性未验证"
|
||||
report.Domains[0].Error = err.Error()
|
||||
report.Diagnosis = DiagnoseAliyunError(err, requestedDomain)
|
||||
return report, fmt.Errorf("阿里云域名读取检查失败:%w", err)
|
||||
}
|
||||
if response == nil || response.Body == nil {
|
||||
return report, errors.New("阿里云未返回有效的域名读取结果")
|
||||
}
|
||||
report.Domains[0].Listed = true
|
||||
report.Domains[0].Status = "可读取,未验证写入和清理权限"
|
||||
// 读取成功必须保持 Verified=false,也不能写入授权缓存。
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// Verify 从目标域名到父级托管区逐级执行最小写入和精确清理。
|
||||
func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (AuthorizationReport, error) {
|
||||
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
|
||||
@ -383,7 +412,7 @@ func DiagnoseAliyunError(err error, domainName string) string {
|
||||
}
|
||||
msg := err.Error()
|
||||
lowered := strings.ToLower(msg)
|
||||
if strings.Contains(lowered, "forbidden.nopermission") || strings.Contains(lowered, "accessdenied") || strings.Contains(lowered, "not authorized") {
|
||||
if strings.Contains(lowered, "forbidden.nopermission") || strings.Contains(lowered, "forbidden.ram") || strings.Contains(lowered, "accessdenied") || strings.Contains(lowered, "not authorized") {
|
||||
return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/%s 的操作权限(Action: alidns:*)", domainName, domainName)
|
||||
}
|
||||
if strings.Contains(lowered, "nosuchdomain") || strings.Contains(lowered, "invaliddomainname.noexist") {
|
||||
|
||||
@ -128,6 +128,11 @@ func TestDiagnoseAliyunError(t *testing.T) {
|
||||
domain string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
err: errors.New("SDKError: Code: Forbidden.RAM, Message: User not authorized to operate on the specified resource."),
|
||||
domain: "example.com",
|
||||
expected: "当前阿里云 RAM 账号未获得域名 example.com 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/example.com 的操作权限(Action: alidns:*)",
|
||||
},
|
||||
{
|
||||
err: errors.New("SDKError: Code: Forbidden.NoPermission, Message: Specified access key is not authorized."),
|
||||
domain: "solit.cn",
|
||||
@ -156,4 +161,4 @@ func TestDiagnoseAliyunError(t *testing.T) {
|
||||
t.Errorf("DiagnoseAliyunError(%v, %s) = %q; want %q", tc.err, tc.domain, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
doc/阿里云部分域名授权验收记录.md
Normal file
119
doc/阿里云部分域名授权验收记录.md
Normal file
@ -0,0 +1,119 @@
|
||||
# 阿里云部分域名授权验收记录
|
||||
|
||||
验收日期:2026 年 9 月 15 日。以下时间为北京时间,程序报告的 `checked_at` 使用 UTC。
|
||||
|
||||
## 一、需求与任务清单
|
||||
|
||||
- [✓] 区分服务器已有域名、RAM 当前权限和用户允许修改的范围,不能互相替代。
|
||||
- [✓] `47.108.78.12` 是 Scert 管理端;`8.137.23.95` 仅作为证书推送目标,不在目标机部署管理端。
|
||||
- [✓] 不调用全局 `DescribeDomains`,只检查明确的候选域名。
|
||||
- [✓] `pcux.cn` 及其子域名不得修改 DNS、证书、私钥或相关服务配置。本次只查询托管区读取权限。
|
||||
- [✓] 对允许写入检查的候选域名,使用随机专用 TXT 记录验证新增与精确删除;不操作业务记录。
|
||||
- [✓] 识别云端实际返回的 `Forbidden.RAM`,补齐中文诊断和回归测试。
|
||||
- [✓] 增加 CLI 只读检查和显式确认后的写入检查入口,不依赖完成 Web 初始化。
|
||||
- [✓] 自动化验证任意 SAN 权限拒绝时,在创建签发器、DNS 挑战 Provider 和推送之前停止。
|
||||
- [!] 真实未授权写入拒绝和 Web 全流程尚未验收,不能用单元测试或只读拒绝替代。
|
||||
|
||||
## 二、目标服务器入口盘点
|
||||
|
||||
来自 `8.137.23.95` 的运行中服务、监听端口和 Nginx 生效配置只读检查。
|
||||
|
||||
| 业务 | 域名 | 后端 | Nginx 证书路径 | 边界 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| FRPS 原生面板 | `a.salg.cn` | `127.0.0.1:7500` | `/etc/ssl/salg.cn/fullchain.crt` | 与下列两项共用证书 |
|
||||
| FRPS 多用户面板 | `b.salg.cn` | `127.0.0.1:7200` | `/etc/ssl/salg.cn/fullchain.crt` | 共用证书 |
|
||||
| FRPS 泛域名代理 | `*.salg.cn` | `127.0.0.1:8001` | `/etc/ssl/salg.cn/fullchain.crt` | 共用证书 |
|
||||
| Gitea | `git.pcux.cn` | `127.0.0.1:3000` | `/ssl/pcux.cn/fullchain.pem` | 禁止修改 |
|
||||
| Xmail Web | `xuans.vcer.cn` | `127.0.0.1:3001` | `/etc/ssl/vcer.cn/fullchain.crt` | 邮件协议证书消费者需另行核实 |
|
||||
|
||||
这些入口共用 Nginx 进程。以上只证明配置对应关系,不是公网 HTTPS、SNI 或生产重载验收。目标机还有 MySQL、SSH、RustDesk 相关进程及端口转发,不能凭监听端口推断其域名或证书关系。
|
||||
|
||||
## 三、真实 RAM 结果
|
||||
|
||||
使用管理机现有加密凭据存储中的阿里云配置。密钥未打印、未写入本记录或提交。
|
||||
|
||||
| 候选托管区 | 实测时间 | 定向读取 | 临时 TXT 新增及删除 | 结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `salg.cn` | 18:59、19:08 | 成功 | 均成功 | 本轮证明可读、可新增及删除专用 TXT |
|
||||
| `vcer.cn` | 18:59、19:08 | 成功 | 均成功 | 本轮证明可读、可新增及删除专用 TXT |
|
||||
| `solit.cn` | 19:00 | 未单独检查 | 均成功 | 本轮证明可新增及删除专用 TXT |
|
||||
| `odacm.cn` | 19:00 | 未单独检查 | 均成功 | 本轮证明可新增及删除专用 TXT |
|
||||
| `apmmk.cn` | 19:00 | 未单独检查 | 均成功 | 本轮证明可新增及删除专用 TXT |
|
||||
| `ifmj.uno` | 19:00 | 未单独检查 | 均成功 | 本轮证明可新增及删除专用 TXT |
|
||||
| `vraq.uno` | 19:00 | 未单独检查 | 均成功 | 本轮证明可新增及删除专用 TXT |
|
||||
| `pcux.cn` | 19:04、19:08 | HTTP 403,`Forbidden.RAM` | 禁止执行,未测试 | 当前 RAM 拒绝该读取操作,同时用户明确禁止修改 |
|
||||
|
||||
### 证据解释
|
||||
|
||||
1. `pcux.cn` 首次只读查询返回 `Forbidden.RAM`,请求 ID 为 `01A0A4BD-FFB7-5DE8-AA81C-C25AB0ABC348`。随后用新增的 Scert 原生 CLI 再次复现。
|
||||
2. 不把读取失败推导成所有 Action 均不可执行;严格结论是 `DescribeDomainRecords` 被拒绝。没有尝试该域名的写入,也没有要求扩大权限。
|
||||
3. 另外七个域名的报告均为 `writable=true`、`cleanup=true`、`verified=true`。`cleanup=true` 表示删除 API 返回成功,不代表本轮额外完成了权威 DNS 缓存消失验证。
|
||||
4. 当前允许范围内的七个候选都通过,不能凭“只授权部分域名”猜测其中某一个应被拒绝。完整账号列表和完整 RAM 策略没有被枚举。
|
||||
5. 结果是检查时刻的能力证据,不是永久授权。读取、新增、更新、删除等权限必须分开理解。
|
||||
|
||||
## 四、代码变更与使用方式
|
||||
|
||||
### 只读检查
|
||||
|
||||
```bash
|
||||
scert dns inspect --domain example.com --config /scert/conf/scert.json
|
||||
```
|
||||
|
||||
- 只调用指定托管区的 `DescribeDomainRecords`,单页一条,不输出业务记录内容。
|
||||
- 不逐级检查父域,不调用全局发现,不执行新增、更新、删除。
|
||||
- 读取成功时退出码为零,但 `verified=false`;报告明确表示未验证写入和清理权限。
|
||||
- 云端拒绝或网络失败退出非零。网络失败不得被解释为已确认权限拒绝。
|
||||
|
||||
### 明确授权后的临时写入检查
|
||||
|
||||
```bash
|
||||
scert dns check --domain example.com --confirm-dns-write --config /scert/conf/scert.json
|
||||
```
|
||||
|
||||
- 未提供 `--confirm-dns-write` 时,在读取凭据和发起云端调用之前拒绝。
|
||||
- 复用项目原有 `AliyunAuthorizationChecker.Verify`,不使用成功缓存冒充实时证明。
|
||||
- 原有检查器可能针对子域逐级尝试父托管区;实际验收本轮只传入已明确允许检查的根域。
|
||||
- 不启动应用、续期任务、通知服务或业务数据库,不改写初始化状态,也不申请或部署证书。
|
||||
- 主密钥或密文存储文件缺失时拒绝,不通过自动生成新主密钥掩盖凭据损坏。
|
||||
|
||||
新增与修改文件:
|
||||
|
||||
- `backend/internal/cli/dns.go`
|
||||
- `backend/internal/cli/dns_test.go`
|
||||
- `backend/internal/cli/root.go`
|
||||
- `backend/internal/dnsprovider/aliyun_authorization.go`
|
||||
- `backend/internal/dnsprovider/aliyun_authorization_test.go`
|
||||
- `backend/internal/certificate/authorization_test.go`
|
||||
- `backend/internal/app/certificate_authorization_test.go`
|
||||
|
||||
## 五、构建与验证
|
||||
|
||||
以下命令本轮执行通过:
|
||||
|
||||
```text
|
||||
go test -p 1 -count=1 -timeout=3m ./...
|
||||
go test -race -p 1 -count=1 -timeout=3m ./internal/cli ./internal/dnsprovider ./internal/certificate ./internal/app ./internal/httpapi
|
||||
go vet -p=1 ./...
|
||||
```
|
||||
|
||||
Linux amd64 独立 CLI 构建并上传到管理机,上传后 SHA256 与本地一致:
|
||||
|
||||
- `/scert/scert-permission-20260915`:`672d8201b1730f1448fdad82d9ded5be1276313c5bb60fbb2bd8b66945da7e41`
|
||||
- `/scert/scert-permission-readonly-20260915`:`7cb3a38e8629737e8f66b405046f1abdacc638d6e4541b18718bfcdd0a5952c9`
|
||||
|
||||
没有替换运行中的 `/scert/scert` 主程序,没有重启管理服务,也没有将以上 CLI 上传至推送目标机。
|
||||
|
||||
## 六、验收结论与未完成项
|
||||
|
||||
**本轮已证明:针对明确候选,系统可以区分读取拒绝、仅可读,以及经专用 TXT 实际证明的新增和删除能力。真实识别出的受限候选是 `pcux.cn`,禁止修改边界得到保留。**
|
||||
|
||||
以下不能标记完成:
|
||||
|
||||
- 真实写入拒绝的签发阻断和完整 Web 页面验收;本轮对此只有自动化回归证据。
|
||||
- 管理服务仍为 `active`,但 `/readyz` 返回 `setup_incomplete`,不是生产就绪。
|
||||
- 运行中 Web 服务尚未升级到本轮新增 CLI 和诊断代码。
|
||||
- 授权缓存存在十分钟有效期,撤权即时性及已有效证书直接推送的授权边界仍需专项验证。
|
||||
- 现有域名新增前置检查在凭据读取错误等情况下存在跳过分支,仍需单独复审失败关闭行为。
|
||||
- 真实 ACME 获取证书、隔离 SSH/SFTP 推送回滚及生产 TLS/SNI 验收尚未由本轮完成。
|
||||
|
||||
本轮未清空业务数据库、未删除用户文件、未改 RAM 策略、未改生产证书、未重载目标机服务。不能据此宣布整个项目通过生产验收。
|
||||
Loading…
Reference in New Issue
Block a user