From f6af21fd2b967d86bed412c427e53245753dab61 Mon Sep 17 00:00:00 2001 From: Miao Date: Sun, 18 Oct 2026 16:03:35 +0800 Subject: [PATCH] DNS: pick an existing Tencent Cloud certificate for EdgeOne HTTPS The one-click DNS sheet used to offer only EdgeOne's free certificate. It now lists issued certificates from Tencent Cloud SSL that cover the name, keeps the free certificate as the default, and deploys the chosen one through EdgeOne (eo.https.set mode=sslcert). The capability checks that the certificate is issued and covers the domain, and undo restores the previous certificate setting. Adds GET /api/certificates/tencent for the picker and shows whether a certificate is hosted/renewed by Tencent Cloud, with its expiry date. --- README.md | 4 +- docs/DESIGN.md | 6 +- internal/actions/cdn.go | 19 +----- internal/actions/cloud.go | 93 +++++++++++++++++++++++----- internal/actions/cloud_test.go | 41 ++++++++++++ internal/actions/registry.go | 17 ++++- internal/api/api.go | 5 ++ internal/app/certs.go | 53 ++++++++++++++++ internal/app/dns.go | 44 +++++++++++-- internal/app/dns_test.go | 62 +++++++++++++++++++ internal/app/tools.go | 4 +- internal/tencent/ssl.go | 39 ++++++++++++ internal/tencent/ssl_test.go | 20 ++++++ internal/tencent/tencenttest/fake.go | 27 +++++++- internal/webui/static/app.js | 42 +++++++++++-- 15 files changed, 418 insertions(+), 58 deletions(-) create mode 100644 internal/tencent/ssl_test.go diff --git a/README.md b/README.md index f16b159..3f73d4c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ | **监控**:每分钟打开一次每个网站,每两分钟看一次服务器,打不开马上提醒 | **备份**:立即备份、每天自动备份(由 1Panel 在服务器上跑),一键恢复 | | ![监控](docs/screenshots/monitor.png) | ![网站备份](docs/screenshots/backup.png) | -**解析**:DNSPod、阿里云云解析和 EdgeOne 放在一起管,哪些名字经过 EdgeOne、哪些还没解析过去一眼就能看到;一键解析能同时把加速域名、CNAME 和免费证书都配好。 +**解析**:DNSPod、阿里云云解析和 EdgeOne 放在一起管,哪些名字经过 EdgeOne、哪些还没解析过去一眼就能看到;一键解析能同时把加速域名、CNAME 和 HTTPS 都配好,证书可以申请 EdgeOne 免费证书,也可以直接选一张托管在腾讯云 SSL 证书里的。 ![解析](docs/screenshots/dns.png) @@ -77,7 +77,7 @@ **解析**:侧边栏「解析」页列出 DNSPod 里每个域名的解析记录(A、AAAA、CNAME、MX、TXT、NS、CAA、SRV,各条线路),可以搜索、按类型筛选,添加、修改、暂停、启用和删除记录;每次修改都先生成清单,确认后执行,执行后能一键撤销。经过 EdgeOne 的名字会标出来(回源到哪里、有没有免费证书),EdgeOne 里有加速域名但解析还没指过去的,页面顶部会提醒并能一键「解析到 EdgeOne」。 -**一键解析**:填一个名字(比如 blog),选择指向「我的服务器」、IP 地址或另一个域名,勾上「经过 EdgeOne」,就生成一份清单:(域名还没接入 EdgeOne 时)先接入 → 添加加速域名并回源到服务器 → 把解析改成 EdgeOne 的 CNAME → 申请免费证书开启 HTTPS;不勾就直接解析过去。已经走 EdgeOne 的记录可以一键「不走 EdgeOne」(直接解析回源站,EdgeOne 里的加速域名保留),普通记录可以一键「开启 EdgeOne」。DNSPod 自带的 NS 记录不能改。 +**一键解析**:填一个名字(比如 blog),选择指向「我的服务器」、IP 地址或另一个域名,勾上「经过 EdgeOne」,就生成一份清单:(域名还没接入 EdgeOne 时)先接入 → 添加加速域名并回源到服务器 → 把解析改成 EdgeOne 的 CNAME → 开启 HTTPS;证书可以申请 EdgeOne 免费证书(自动续签),也可以从腾讯云 SSL 证书里选一张已经签发、包含这个域名的证书(包括托管的证书),不会重复申请。不勾就直接解析过去。已经走 EdgeOne 的记录可以一键「不走 EdgeOne」(直接解析回源站,EdgeOne 里的加速域名保留),普通记录可以一键「开启 EdgeOne」。DNSPod 自带的 NS 记录不能改。 **存储**:侧边栏「存储」页管理腾讯云 COS(子账号要有 QcloudCOSFullAccess;只想看不想改用 QcloudCOSReadOnlyAccess)。左边是所有存储桶和它们的访问权限,公开读写的标红。选中一个桶: diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ac5f28e..c0e0aeb 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1214,7 +1214,7 @@ EdgeOne 安全策略、新建站点、自动化助手连接和 1Panel 建站见 | 操作 | 说明 | 回滚 | |---|---|---| -| `eo.https.set`(已有) | 经过 EdgeOne 的网站用 EdgeOne 免费证书,EdgeOne 自动续签;EdgeOne 用 HTTP 回源时源站不需要证书 | 恢复原来的证书设置 | +| `eo.https.set`(已有) | 经过 EdgeOne 的网站用 EdgeOne 免费证书(`mode=eofreecert`,EdgeOne 自动续签),或者用腾讯云 SSL 证书里已有的证书(`mode=sslcert`,`cert` 填证书 ID,证书要包含这个加速域名,到期前自己续签或换一张);EdgeOne 用 HTTP 回源时源站不需要证书 | 恢复原来的证书设置 | | `cert.issue` | 1Panel 服务器:让 1Panel 向 Let's Encrypt 申请证书并开启自动续签(1Panel 在服务器上自动续签)。默认 HTTP 验证(域名要能访问到服务器,经过 EdgeOne 也可以);泛域名必须用 DNS 验证,需要 1Panel 里有 DNS 账号;1Panel 还没有 Let's Encrypt 账号时用给出的邮箱注册。申请下来后给同名网站开启 HTTPS(默认 HTTP 和 HTTPS 都能访问,避免 EdgeOne HTTP 回源时循环跳转),并在服务器本机用 curl 验证。同一张证书已经有了就直接使用 | 恢复网站原来的 HTTPS 设置,删除新申请的证书 | | `cert.renew` | 立即续签 1Panel 里的一张证书,等到新证书签发下来才算完成 | 不需要 | | `cert.autorenew.set` | 开关 1Panel 证书的自动续签 | 改回原来的设置 | @@ -1393,11 +1393,11 @@ EdgeOne 的统计接口只有请求数、流量、带宽这类总量和排行, - DNSPod 自带的 NS 记录(`DefaultNS`)不能修改、暂停或删除;记录值按类型检查(CAA 要像 `0 issue "letsencrypt.org"`,SRV 要是「优先级 权重 端口 目标」),线路名只允许中文、字母、数字和少数符号; - `ModifyRecord` 带上原来的 `Status` 和 `Weight`(不带的话 DNSPod 会把暂停的记录重新启用、清掉权重);线路列表来自 `DescribeRecordLineList`(按域名的套餐),读不到时只提供「默认」; - 页面上每条记录标出 EdgeOne 的情况:名字是 EdgeOne 加速域名、而且默认线路 CNAME 到它分配的地址,就是「经过 EdgeOne」(显示回源地址和证书);其他线路直接解析到别处的,标「这条线路的访客不经过 EdgeOne」;EdgeOne 里有加速域名、但没有解析过去的,列在页面顶部,可以一键「解析到 EdgeOne」(`dns.record.set point_to=eo`); -- **一键解析**(`ProposeDNS`,op=quick):名字 + 指向(我的服务器用它的地址,内网地址拒绝;IP 用 A/AAAA;另一个域名用 CNAME)。不经过 EdgeOne 就是一步 `dns.record.set`;经过 EdgeOne 时按现状组合:域名还没接入就先 `eo.zone.create`(要有能绑定站点的套餐,没有就直接说明,不生成清单;包含中国大陆的区域提示要备案),加速域名不存在就 `eo.domain.add`、已存在但回源不同就 `eo.origin.set`,然后 `dns.record.set point_to=eo`,勾了 HTTPS 再加 `eo.https.set`。NS 方式接入 EdgeOne 的域名不在 DNSPod 管理解析,直接说明; +- **一键解析**(`ProposeDNS`,op=quick):名字 + 指向(我的服务器用它的地址,内网地址拒绝;IP 用 A/AAAA;另一个域名用 CNAME)。不经过 EdgeOne 就是一步 `dns.record.set`;经过 EdgeOne 时按现状组合:域名还没接入就先 `eo.zone.create`(要有能绑定站点的套餐,没有就直接说明,不生成清单;包含中国大陆的区域提示要备案),加速域名不存在就 `eo.domain.add`、已存在但回源不同就 `eo.origin.set`,然后 `dns.record.set point_to=eo`,勾了 HTTPS 再加 `eo.https.set`:默认申请 EdgeOne 免费证书,也可以从腾讯云 SSL 证书里选一张已签发、包含这个名字的证书(`mode=sslcert` + `cert`,列表来自 `GET /api/certificates/tencent`,托管在腾讯云的证书会注明会自动续期)。NS 方式接入 EdgeOne 的域名不在 DNSPod 管理解析,直接说明; - 「不走 EdgeOne」(op=eo_off)把名字直接解析回 EdgeOne 里记的源站,加速域名保留,清单里说明缓存、防护和证书不再起作用、服务器 IP 会公开;普通 A/CNAME 记录可以「开启 EdgeOne」(用它现在的值当源站走一键解析); - 清单弹窗关掉时如果还在执行,页面继续跟进,执行完自动刷新记录;撤销后也会刷新。 -测试:四个新操作在模拟的 DNSPod 上添加 MX、重复添加跳过、CNAME 冲突被拒、暂停后修改仍是暂停、撤销修改和暂停、删除后加回、撤销已经不存在的新增记录、自带 NS 记录拒绝、各类型记录值和线路名的检查;一键解析直接解析、经过 EdgeOne(三步真实执行)、回源不同改用 `eo.origin.set`、「不走 EdgeOne」、未解析的加速域名提示和「解析到 EdgeOne」、单条记录的增改停删和撤销、内网地址和错误值被拒、没接入的域名先接入、没有可绑定套餐时说明、NS 接入时说明。浏览器里在演示数据上走了一遍:提醒、解析到 EdgeOne、一键解析(经过和不经过 EdgeOne)、错误提示、添加 TXT、修改、启用、删除再撤销、「不走 EdgeOne」,看了浅色、深色和手机宽度。还没有在真实的 DNSPod 和 EdgeOne 账号上执行过。 +测试:四个新操作在模拟的 DNSPod 上添加 MX、重复添加跳过、CNAME 冲突被拒、暂停后修改仍是暂停、撤销修改和暂停、删除后加回、撤销已经不存在的新增记录、自带 NS 记录拒绝、各类型记录值和线路名的检查;一键解析直接解析、经过 EdgeOne(三步真实执行)、用腾讯云 SSL 证书里已有的证书开启 HTTPS(证书不包含域名或找不到时拒绝)、回源不同改用 `eo.origin.set`、「不走 EdgeOne」、未解析的加速域名提示和「解析到 EdgeOne」、单条记录的增改停删和撤销、内网地址和错误值被拒、没接入的域名先接入、没有可绑定套餐时说明、NS 接入时说明。浏览器里在演示数据上走了一遍:提醒、解析到 EdgeOne、一键解析(经过和不经过 EdgeOne)、错误提示、添加 TXT、修改、启用、删除再撤销、「不走 EdgeOne」,看了浅色、深色和手机宽度。还没有在真实的 DNSPod 和 EdgeOne 账号上执行过。 ### 存储:腾讯云 COS(已完成) diff --git a/internal/actions/cdn.go b/internal/actions/cdn.go index 7e639e7..81c2af7 100644 --- a/internal/actions/cdn.go +++ b/internal/actions/cdn.go @@ -207,7 +207,7 @@ func applyCDNHTTPS(ctx context.Context, env *Env, v map[string]string, out *Outc return refused(out, "腾讯云 SSL 证书里没有证书 %s", cert) case found.Status != 1: return refused(out, "证书 %s 还不能用(%s)", cert, found.StatusName) - case !sslCovers(*found, d.Domain): + case !found.Covers(d.Domain): return refused(out, "证书 %s 不包含 %s(证书的域名:%s)", cert, d.Domain, strings.Join(append([]string{found.Domain}, found.SANs...), "、")) } } @@ -233,23 +233,6 @@ func applyCDNHTTPS(ctx context.Context, env *Env, v map[string]string, out *Outc return *out } -// sslCovers reports whether a certificate is for host (a wildcard covers -// one level). -func sslCovers(c tencent.SSLCert, host string) bool { - for _, n := range append([]string{c.Domain}, c.SANs...) { - n = strings.ToLower(n) - if n == host { - return true - } - if rest, ok := strings.CutPrefix(n, "*."); ok { - if sub, ok := strings.CutSuffix(host, "."+rest); ok && sub != "" && !strings.Contains(sub, ".") { - return true - } - } - } - return false -} - func cdnStatusText(on bool) string { if on { return "启用" diff --git a/internal/actions/cloud.go b/internal/actions/cloud.go index 1439412..953587a 100644 --- a/internal/actions/cloud.go +++ b/internal/actions/cloud.go @@ -512,7 +512,7 @@ func undoEODomain(ctx context.Context, env *Env, undo map[string]string) error { func applyEOHTTPS(ctx context.Context, env *Env, v map[string]string, out *Outcome, report func(string, ...any)) Outcome { c := env.Cloud - name, mode := strings.ToLower(v["domain"]), v["mode"] + name, mode, certID := strings.ToLower(v["domain"]), v["mode"], strings.TrimSpace(v["cert"]) z, err := findZone(ctx, c, name) if err != nil { out.Status = StatusRefused @@ -533,11 +533,56 @@ func applyEOHTTPS(ctx context.Context, env *Env, v map[string]string, out *Outco } return false } - if d.Certificate.Mode == mode && (mode == "disable" || deployed(d)) { - report("%s 的证书设置已经是 %s,不需要修改", name, mode) - out.Status = StatusDone - out.Undo = map[string]string{} - return *out + // The selected certificate from Tencent Cloud's SSL service: it has to + // be issued and cover this name before anything is changed. + certLabel := "" + if mode == "sslcert" { + certs, err := c.SSLCertificates(ctx) + if err != nil { + out.Status = StatusRefused + out.logf("读取腾讯云 SSL 证书失败:%v", err) + return *out + } + var found *tencent.SSLCert + for i := range certs { + if certs[i].ID == certID { + found = &certs[i] + } + } + switch { + case found == nil: + out.Status = StatusRefused + out.logf("腾讯云 SSL 证书里没有证书 %s", certID) + return *out + case found.Status != 1: + out.Status = StatusRefused + out.logf("证书 %s 还不能用(%s)", certID, orDash(found.StatusName)) + return *out + case !found.Covers(name): + out.Status = StatusRefused + out.logf("证书 %s 不包含 %s(证书的域名:%s)", certID, name, strings.Join(found.Names(), "、")) + return *out + } + certLabel = strings.Join(found.Names(), "、") + } + // The status of the certificate this step cares about: EdgeOne's free + // one, or the chosen certificate from SSL. + certState := func(d tencent.AccelerationDomain) string { + state := "" + for _, cert := range d.Certificate.List { + if mode == "eofreecert" || cert.CertID == certID { + state = cert.Status + } + } + return state + } + if d.Certificate.Mode == mode { + if mode == "disable" || (mode == "eofreecert" && deployed(d)) || (mode == "sslcert" && certState(d) == "deployed") { + report("%s 的证书设置已经是 %s,不需要修改", name, mode) + out.Status = StatusDone + out.Undo = map[string]string{} + return *out + } } if mode == "eofreecert" && z.Type == "partial" { // A free certificate is validated through the domain's CNAME. @@ -565,12 +610,17 @@ func applyEOHTTPS(ctx context.Context, env *Env, v map[string]string, out *Outco prev = "disable" } out.Undo["zone_id"], out.Undo["domain"], out.Undo["mode"], out.Undo["cert_ids"] = z.ZoneID, name, prev, strings.Join(ids, ",") - if mode == "disable" { + var setIDs []string + switch mode { + case "disable": report("正在关闭 %s 的 HTTPS 证书", name) - } else { + case "sslcert": + setIDs = []string{certID} + report("正在把腾讯云 SSL 证书 %s(%s)部署到 %s", certID, certLabel, name) + default: report("正在为 %s 申请并部署 EdgeOne 免费证书(到期自动续签)", name) } - if err := c.SetCertificate(ctx, z.ZoneID, name, mode, nil); err != nil { + if err := c.SetCertificate(ctx, z.ZoneID, name, mode, setIDs); err != nil { out.Status = StatusRefused out.Undo = map[string]string{} out.logf("设置失败:%v", err) @@ -584,17 +634,18 @@ func applyEOHTTPS(ctx context.Context, env *Env, v map[string]string, out *Outco var state string _, _ = waitFor(ctx, env, 30, func() (bool, error) { d, _, err := c.AccelerationDomain(ctx, z.ZoneID, name) - state = "" - for _, cert := range d.Certificate.List { - state = cert.Status - } - return deployed(d) || state == "failed", err + state = certState(d) + return state == "deployed" || state == "failed", err }) switch state { case "deployed": report("完成:证书已部署,现在可以用 https://%s 访问", name) case "failed": - report("免费证书申请失败,正在恢复原来的证书设置") + if mode == "sslcert" { + report("证书部署失败,正在恢复原来的证书设置") + } else { + report("免费证书申请失败,正在恢复原来的证书设置") + } ids := []string(nil) if out.Undo["cert_ids"] != "" { ids = strings.Split(out.Undo["cert_ids"], ",") @@ -605,10 +656,18 @@ func applyEOHTTPS(ctx context.Context, env *Env, v map[string]string, out *Outco return *out } out.Status = StatusRolledBack - out.logf("可以在 EdgeOne 控制台的「证书」里查看申请失败的原因") + if mode == "sslcert" { + out.logf("可以在 EdgeOne 控制台的「证书」里查看部署失败的原因") + } else { + out.logf("可以在 EdgeOne 控制台的「证书」里查看申请失败的原因") + } return *out default: - report("完成:已提交免费证书申请(当前状态:%s),一般几分钟内签发,可以稍后让 AI 查一下", state) + if mode == "sslcert" { + report("完成:已提交证书部署(当前状态:%s),一般几分钟内生效", orDash(state)) + } else { + report("完成:已提交免费证书申请(当前状态:%s),一般几分钟内签发,可以稍后让 AI 查一下", orDash(state)) + } } out.Status = StatusDone return *out diff --git a/internal/actions/cloud_test.go b/internal/actions/cloud_test.go index 0f9cd4e..ba28c69 100644 --- a/internal/actions/cloud_test.go +++ b/internal/actions/cloud_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/bocmiao/CloudConsoleWithAI/internal/tencent" "github.com/bocmiao/CloudConsoleWithAI/internal/tencent/tencenttest" ) @@ -74,6 +75,46 @@ func TestEdgeOneSiteFlowAndUndo(t *testing.T) { } } +func TestEdgeOneCertificateFromSSL(t *testing.T) { + env, f := cloudEnv(t) + ctx := context.Background() + add := mustResolve(t, "eo.domain.add", map[string]any{"domain": "blog.example.com", "origin": "81.68.79.253"}) + if out := Apply(ctx, env, add, nil); out.Status != StatusDone { + t.Fatalf("eo.domain.add: %+v", out) + } + f.SSLCerts = append(f.SSLCerts, &tencent.SSLCert{ID: "ssl-own", Domain: "*.example.com", SANs: []string{"*.example.com"}, + Status: 1, StatusName: "已通过", Product: "TrustAsia C1 DV Free"}) + + https := mustResolve(t, "eo.https.set", map[string]any{"domain": "blog.example.com", "mode": "sslcert", "cert": "ssl-own"}) + out := Apply(ctx, env, https, nil) + d := f.Domain("blog.example.com") + if out.Status != StatusDone || d.CertMode != "sslcert" || len(d.CertIDs) != 1 || d.CertIDs[0] != "ssl-own" { + t.Fatalf("eo.https.set sslcert: %+v domain=%+v", out, d) + } + if again := Apply(ctx, env, https, nil); again.Status != StatusDone || len(again.Undo) != 0 { + t.Fatalf("setting the same certificate again should change nothing: %+v", again) + } + + // A certificate for another name is refused before anything changes. + bad := mustResolve(t, "eo.https.set", map[string]any{"domain": "blog.example.com", "mode": "sslcert", "cert": "ssl-abc"}) + if refused := Apply(ctx, env, bad, nil); refused.Status != StatusRefused || !strings.Contains(strings.Join(refused.Log, ""), "不包含") { + t.Fatalf("certificate for another name: %+v", refused) + } + if d := f.Domain("blog.example.com"); d.CertMode != "sslcert" || d.CertIDs[0] != "ssl-own" { + t.Fatalf("refused change touched the domain: %+v", d) + } + + // mode=sslcert needs a certificate ID. + if _, err := Resolve("eo.https.set", map[string]any{"domain": "blog.example.com", "mode": "sslcert"}, "-"); err == nil { + t.Fatal("mode=sslcert without cert accepted") + } + + // Undo goes back to the certificate setting before it. + if u := Undo(ctx, env, https, out.Undo); u.Status != StatusUndone || f.Domain("blog.example.com").CertMode != "disable" { + t.Fatalf("undo https: %+v domain=%+v", u, f.Domain("blog.example.com")) + } +} + func TestDNSRecordCases(t *testing.T) { env, f := cloudEnv(t) ctx := context.Background() diff --git a/internal/actions/registry.go b/internal/actions/registry.go index 04ab0ea..1db5ae4 100644 --- a/internal/actions/registry.go +++ b/internal/actions/registry.go @@ -177,10 +177,21 @@ func init() { Name: "eo.https.set", Title: "设置 EdgeOne HTTPS 证书", Risk: core.R2, Reversible: true, Params: []Param{ {Name: "domain", Kind: "host", Required: true, Desc: "EdgeOne 加速域名,例如 blog.example.com"}, - {Name: "mode", Kind: "enum", Enum: []string{"eofreecert", "disable"}, Default: "eofreecert", - Desc: "eofreecert:申请并部署免费证书(自动续签,要求域名已经解析到 EdgeOne);disable:关闭"}, + {Name: "mode", Kind: "enum", Enum: []string{"eofreecert", "sslcert", "disable"}, Default: "eofreecert", + Desc: "eofreecert:申请并部署 EdgeOne 免费证书(自动续签,要求域名已经解析到 EdgeOne);sslcert:部署腾讯云 SSL 证书里已有的证书(要填 cert);disable:关闭"}, + {Name: "cert", Kind: "name", + Desc: "mode=sslcert 时用:腾讯云 SSL 证书的 ID(ssl- 开头,certificates 或 ssl.cert.apply 返回的 ID);证书要包含这个加速域名"}, }, - Impls: map[string]Impl{"*": {Via: "腾讯云接口", Cloud: "eo_https", Downtime: "不影响访问,证书签发一般需要几分钟", + Check: func(v map[string]string) error { + if v["mode"] == "sslcert" && v["cert"] == "" { + return fmt.Errorf("mode=sslcert 时要填 cert(腾讯云 SSL 证书 ID)") + } + if v["mode"] != "sslcert" && v["cert"] != "" { + return fmt.Errorf("cert 只在 mode=sslcert 时填") + } + return nil + }, + Impls: map[string]Impl{"*": {Via: "腾讯云接口", Cloud: "eo_https", Downtime: "不影响访问,证书签发或部署一般需要几分钟", Undo: "把证书设置恢复成修改前的样子"}}, }) register(&Capability{ diff --git a/internal/api/api.go b/internal/api/api.go index cc39429..7059f1b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -118,6 +118,7 @@ func (s *Server) routes() { api("PUT /api/settings/ai", s.putAISettings) api("POST /api/settings/ai/test", s.testAI) api("GET /api/certificates", s.getCertificates) + api("GET /api/certificates/tencent", s.tencentSSLCerts) api("GET /api/settings/free-command", s.getFreeCommand) api("PUT /api/settings/free-command", s.putFreeCommand) api("GET /api/settings/tencent", s.getTencent) @@ -853,6 +854,10 @@ func (s *Server) getCertificates(_ http.ResponseWriter, r *http.Request) (any, e return s.app.LatestCertificates(r.Context()) } +func (s *Server) tencentSSLCerts(_ http.ResponseWriter, r *http.Request) (any, error) { + return s.app.SSLCertOptions(r.Context()) +} + func (s *Server) getFreeCommand(_ http.ResponseWriter, _ *http.Request) (any, error) { return s.app.FreeCommand() } diff --git a/internal/app/certs.go b/internal/app/certs.go index fd84f5f..46b0eca 100644 --- a/internal/app/certs.go +++ b/internal/app/certs.go @@ -304,6 +304,59 @@ func (a *App) eoCerts(ctx context.Context, c *tencent.Client) ([]CertEntry, stri return out, "" } +// SSLCertOption is one certificate in Tencent Cloud's SSL 证书, for picking +// an existing certificate on the 解析 page's HTTPS setting. +type SSLCertOption struct { + ID string `json:"id"` + Names []string `json:"names"` // main domain and subject alternative names + Alias string `json:"alias,omitempty"` + Issuer string `json:"issuer,omitempty"` + Expires string `json:"expires,omitempty"` // YYYY-MM-DD + DaysLeft *int `json:"daysLeft,omitempty"` + AutoRenew bool `json:"autoRenew"` // hosted by Tencent Cloud, renewed there + Usable bool `json:"usable"` // issued and not expired + Status string `json:"status"` +} + +// SSLCertOptions lists the account's certificates in Tencent Cloud's SSL +// 证书, newest and usable ones first. +func (a *App) SSLCertOptions(ctx context.Context) ([]SSLCertOption, error) { + c, err := needTencent(a) + if err != nil { + return nil, err + } + list, err := c.SSLCertificates(ctx) + if err != nil { + return nil, fmt.Errorf("读取腾讯云 SSL 证书失败:%w", err) + } + out := make([]SSLCertOption, 0, len(list)) + for _, s := range list { + o := SSLCertOption{ID: s.ID, Names: uniqLower(s.Names()), Alias: s.Alias, Issuer: orDash(s.Product), + Status: orDash(s.StatusName), AutoRenew: s.HostingStatus != nil && *s.HostingStatus >= 0} + if t, ok := parseAnyTime(s.EndTime); ok { + o.Expires, o.DaysLeft = t.Format("2006-01-02"), days(t) + } + o.Usable = s.Status == 1 && (o.DaysLeft == nil || *o.DaysLeft >= 0) + switch s.Status { + case 1: + o.Status = "已签发" + case 3: + o.Status = "已过期" + } + out = append(out, o) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Usable != out[j].Usable { + return out[i].Usable + } + if out[i].DaysLeft != nil && out[j].DaysLeft != nil && *out[i].DaysLeft != *out[j].DaysLeft { + return *out[i].DaysLeft > *out[j].DaysLeft + } + return strings.Join(out[i].Names, ",") < strings.Join(out[j].Names, ",") + }) + return out, nil +} + // sslCerts reads Tencent Cloud's SSL certificate service. func sslCerts(ctx context.Context, c *tencent.Client) ([]CertEntry, string) { list, err := c.SSLCertificates(ctx) diff --git a/internal/app/dns.go b/internal/app/dns.go index d6a2f79..24959a8 100644 --- a/internal/app/dns.go +++ b/internal/app/dns.go @@ -320,7 +320,8 @@ type DNSRequest struct { Target string `json:"target"` // quick: server, ip, host ServerID int64 `json:"serverId"` EdgeOne bool `json:"edgeone"` - HTTPS bool `json:"https"` // quick with EdgeOne: a free certificate + HTTPS bool `json:"https"` // quick with EdgeOne: turn on HTTPS + Cert string `json:"cert"` // quick with EdgeOne: a Tencent SSL certificate ID; empty means EdgeOne's free certificate Protocol string `json:"protocol"` // quick with EdgeOne: HTTP or HTTPS to the origin Area string `json:"area"` // quick with EdgeOne, new site: mainland, overseas, global @@ -641,9 +642,22 @@ func (a *App) dnsQuick(ctx context.Context, c *tencent.Client, req DNSRequest) ( steps = append(steps, core.Step{Capability: "dns.record.set", Summary: fmt.Sprintf("把 %s 的解析改成 EdgeOne 分配的 CNAME", name), Params: map[string]any{"domain": req.Domain, "subdomain": req.Sub, "point_to": "eo"}}) if req.HTTPS { - steps = append(steps, core.Step{Capability: "eo.https.set", Summary: fmt.Sprintf("给 %s 申请 EdgeOne 免费证书,开启 HTTPS(自动续签)", name), - Params: map[string]any{"domain": name, "mode": "eofreecert"}}) - notes = append(notes, "免费证书要等解析生效后才能申请下来;如果这一步提示还没生效,过几分钟再单独执行它就行。") + if certID := strings.TrimSpace(req.Cert); certID != "" { + cert, err := sslCertFor(ctx, c, certID, name) + if err != nil { + return "", "", nil, err + } + steps = append(steps, core.Step{Capability: "eo.https.set", Summary: fmt.Sprintf("用腾讯云 SSL 证书 %s 给 %s 开启 HTTPS", strings.Join(cert.Names(), "、"), name), + Params: map[string]any{"domain": name, "mode": "sslcert", "cert": cert.ID}}) + notes = append(notes, "HTTPS 用腾讯云 SSL 证书里已有的证书("+strings.Join(cert.Names(), "、")+"),不会申请新证书;到期前要在腾讯云 SSL 证书控制台续签或换一张。") + if !existing { + notes = append(notes, "加速域名刚添加时还在配置中,证书部署可能失败;如果这一步提示失败,等加速域名上线后再单独执行一次就行。") + } + } else { + steps = append(steps, core.Step{Capability: "eo.https.set", Summary: fmt.Sprintf("给 %s 申请 EdgeOne 免费证书,开启 HTTPS(自动续签)", name), + Params: map[string]any{"domain": name, "mode": "eofreecert"}}) + notes = append(notes, "免费证书要等解析生效后才能申请下来;如果这一步提示还没生效,过几分钟再单独执行它就行。") + } } if proto == "HTTP" { notes = append(notes, "EdgeOne 用 HTTP 回源,服务器上不需要证书。") @@ -655,6 +669,28 @@ func (a *App) dnsQuick(ctx context.Context, c *tencent.Client, req DNSRequest) ( return "解析 " + name + " → " + via + "(经过 EdgeOne)", reason, steps, nil } +// sslCertFor reads one certificate from Tencent Cloud's SSL service and +// makes sure it can serve name. +func sslCertFor(ctx context.Context, c *tencent.Client, id, name string) (tencent.SSLCert, error) { + list, err := c.SSLCertificates(ctx) + if err != nil { + return tencent.SSLCert{}, fmt.Errorf("读取腾讯云 SSL 证书失败:%w", err) + } + for _, s := range list { + if s.ID != id { + continue + } + switch { + case s.Status != 1: + return s, userErr("证书 %s 还不能用(%s)", id, orDash(s.StatusName)) + case !s.Covers(name): + return s, userErr("证书 %s 不包含 %s(证书的域名:%s)", id, name, strings.Join(s.Names(), "、")) + } + return s, nil + } + return tencent.SSLCert{}, userErr("腾讯云 SSL 证书里没有证书 %s,请刷新后重新选", id) +} + func (a *App) eoDomainFor(ctx context.Context, c *tencent.Client, name string) (tencent.AccelerationDomain, error) { zones, err := c.Zones(ctx) if err != nil { diff --git a/internal/app/dns_test.go b/internal/app/dns_test.go index cda9a2d..4147d65 100644 --- a/internal/app/dns_test.go +++ b/internal/app/dns_test.go @@ -190,6 +190,68 @@ func TestDNSPage(t *testing.T) { } } +func TestDNSQuickWithOwnCertificate(t *testing.T) { + a := newApp(t) + f := tencenttest.Start(t) + a.TencentEndpoint = f.Endpoint + a.PollInterval = 10 * time.Millisecond + ctx := context.Background() + if _, err := a.SaveTencent(tencenttest.SecretID, tencenttest.SecretKey); err != nil { + t.Fatal(err) + } + f.SSLCerts = append(f.SSLCerts, &tencent.SSLCert{ID: "ssl-own", Domain: "*.example.com", SANs: []string{"*.example.com"}, + Status: 1, StatusName: "已通过", EndTime: "2030-01-02 15:04:05"}) + + opts, err := a.SSLCertOptions(ctx) + var own *SSLCertOption + for i := range opts { + if opts[i].ID == "ssl-own" { + own = &opts[i] + } + } + if err != nil || own == nil || !own.Usable || len(own.Names) != 1 || own.Names[0] != "*.example.com" { + t.Fatalf("options = %+v %v", opts, err) + } + + // A certificate from Tencent Cloud's SSL service instead of a free one. + v, err := a.ProposeDNS(ctx, DNSRequest{Op: "quick", Domain: "example.com", Sub: "blog", Target: "ip", Value: "5.6.7.8", + EdgeOne: true, HTTPS: true, Cert: "ssl-own"}) + if err != nil || caps(v) != "eo.domain.add dns.record.set eo.https.set" { + t.Fatalf("quick with own certificate = %+v %v", v, err) + } + https := v.StepList[len(v.StepList)-1] + if https.Params["mode"] != "sslcert" || https.Params["cert"] != "ssl-own" || !strings.Contains(https.Summary, "*.example.com") || + !strings.Contains(v.Reason, "不会申请新证书") { + t.Fatalf("certificate step = %+v reason=%s", https, v.Reason) + } + var all []int + for i := range v.StepList { + all = append(all, i) + } + if _, err := a.ExecutePlan(v.ID, all); err != nil { + t.Fatal(err) + } + done := waitPlan(t, a, v.ID) + for i, s := range done.StepList { + if s.Status != actions.StatusDone { + t.Fatalf("step %d = %+v", i+1, s) + } + } + if d := f.Domain("blog.example.com"); d == nil || d.CertMode != "sslcert" || len(d.CertIDs) != 1 || d.CertIDs[0] != "ssl-own" { + t.Fatalf("domain = %+v", d) + } + + // A certificate that does not cover the name is refused by the form. + if _, err := a.ProposeDNS(ctx, DNSRequest{Op: "quick", Domain: "example.com", Sub: "shop", Target: "ip", Value: "5.6.7.8", + EdgeOne: true, HTTPS: true, Cert: "ssl-abc"}); err == nil || !isUserErr(err) || !strings.Contains(err.Error(), "不包含") { + t.Fatalf("certificate for another name: %v", err) + } + if _, err := a.ProposeDNS(ctx, DNSRequest{Op: "quick", Domain: "example.com", Sub: "shop", Target: "ip", Value: "5.6.7.8", + EdgeOne: true, HTTPS: true, Cert: "ssl-nope"}); err == nil || !strings.Contains(err.Error(), "没有证书") { + t.Fatalf("missing certificate: %v", err) + } +} + func must(v PlanView, err error) PlanView { if err != nil { panic(err) diff --git a/internal/app/tools.go b/internal/app/tools.go index 5ef2e75..1e04b52 100644 --- a/internal/app/tools.go +++ b/internal/app/tools.go @@ -48,7 +48,7 @@ run_check 做只读检查,server_files 读任意配置文件和日志、列出 - 先查清楚:域名是否在 DNSPod、EdgeOne 里有没有它所在的站点、加速域名是否已经存在、这个主机记录现在解析到哪里、网站在哪台服务器(公网 IP 用 list_servers 查); 1Panel 服务器用 panel_websites 看网站是否已经建好、有哪些应用可以代理; - 完整的清单顺序:eo.zone.create(EdgeOne 里还没有这个主域名的站点时)→ site.create(服务器上还没有这个网站时,1Panel 服务器)→ eo.domain.add(回源到服务器公网 IP) - → dns.record.set(point_to=eo)→ eo.https.set(免费证书)。已经做好的步骤不要重复加; + → dns.record.set(point_to=eo)→ eo.https.set(免费证书,或者用腾讯云 SSL 证书里已有的证书)。已经做好的步骤不要重复加; - eo.zone.create 要绑定账号里现有的、还能绑定站点的套餐(tencent_eo 不填 domain 会列出套餐);没有可用套餐时告诉用户先在 EdgeOne 控制台购买或领取(涉及计费,要用户自己操作)。 加速区域包括中国大陆时域名要有 ICP 备案,没备案用 overseas; - 腾讯云和服务器操作可以放在同一份清单里(server_id 填这台服务器);只涉及腾讯云的清单 server_id 填 0; @@ -119,7 +119,7 @@ EdgeOne 安全防护:先用 tencent_eo_security 看现有规则,再结合访 - 已经封禁了哪些 IP 用 blocked_ips 看完整列表(tencent_eo_security 里的规则内容会截断)。 HTTPS 证书:先用 certificates 看现状。 -- 经过 EdgeOne 的网站,访问者看到的是 EdgeOne 边缘的证书:用 eo.https.set 申请 EdgeOne 免费证书,EdgeOne 会自动续签;EdgeOne 用 HTTP 回源时源站不需要证书; +- 经过 EdgeOne 的网站,访问者看到的是 EdgeOne 边缘的证书:用 eo.https.set 申请 EdgeOne 免费证书(mode=eofreecert,默认),EdgeOne 会自动续签;用户想用腾讯云 SSL 证书里已有的证书(比如上传的泛域名证书)时,mode=sslcert、cert 填 certificates 里的 ssl- 开头的 ID,证书必须包含这个加速域名;EdgeOne 用 HTTP 回源时源站不需要证书; - 直接访问服务器的网站(1Panel):用 cert.issue 让 1Panel 向 Let's Encrypt 申请并开启自动续签(1Panel 在服务器上 24 小时自动续签,不依赖 Miao Panel 开着),同时给网站开启 HTTPS; 默认 HTTP 验证,域名必须已经解析到这台服务器(或经过 EdgeOne);泛域名证书必须用 method=dns,需要 1Panel 里有 DNS 账号(panel_websites 会列出证书账号和 DNS 账号);1Panel 里还没有 Let's Encrypt 账号时要向用户要一个邮箱; 网站前面有 EdgeOne 并且用 HTTP 回源时,http_mode 保持 HTTPAlso,不要设成跳转,否则会循环跳转; diff --git a/internal/tencent/ssl.go b/internal/tencent/ssl.go index 84f72ee..6d03441 100644 --- a/internal/tencent/ssl.go +++ b/internal/tencent/ssl.go @@ -2,6 +2,7 @@ package tencent import ( "context" + "strings" ) const sslVersion = "2019-12-05" @@ -23,6 +24,44 @@ type SSLCert struct { HostingStatus *int `json:"HostingStatus"` // 0/5/10 hosted (renewed and replaced automatically), -1 not } +// Names lists the names a certificate is for: its main domain and the +// subject alternative names, without repeats. +func (s SSLCert) Names() []string { + seen := map[string]bool{} + var out []string + add := func(n string) { + n = strings.TrimSpace(n) + if n == "" || seen[strings.ToLower(n)] { + return + } + seen[strings.ToLower(n)] = true + out = append(out, n) + } + add(s.Domain) + for _, n := range s.SANs { + add(n) + } + return out +} + +// Covers reports whether the certificate is for host: a name itself, or one +// level under a wildcard. +func (s SSLCert) Covers(host string) bool { + host = strings.ToLower(strings.TrimSuffix(host, ".")) + for _, n := range s.Names() { + n = strings.ToLower(strings.TrimSuffix(n, ".")) + if n == host { + return true + } + if rest, ok := strings.CutPrefix(n, "*."); ok { + if sub, ok := strings.CutSuffix(host, "."+rest); ok && sub != "" && !strings.Contains(sub, ".") { + return true + } + } + } + return false +} + // SSLCertificates lists the account's server certificates. func (c *Client) SSLCertificates(ctx context.Context) ([]SSLCert, error) { var out struct { diff --git a/internal/tencent/ssl_test.go b/internal/tencent/ssl_test.go new file mode 100644 index 0000000..f1341eb --- /dev/null +++ b/internal/tencent/ssl_test.go @@ -0,0 +1,20 @@ +package tencent + +import "testing" + +func TestSSLCertNamesAndCovers(t *testing.T) { + c := SSLCert{ID: "cert-1", Domain: "*.miao.club", SANs: []string{"*.miao.club", "miao.club"}} + if names := c.Names(); len(names) != 2 || names[0] != "*.miao.club" || names[1] != "miao.club" { + t.Fatalf("names = %v", names) + } + for _, host := range []string{"blog.miao.club", "miao.club", "BLOG.Miao.Club"} { + if !c.Covers(host) { + t.Errorf("%s not covered", host) + } + } + for _, host := range []string{"a.b.miao.club", "miao.club.example.com", "other.com", ""} { + if c.Covers(host) { + t.Errorf("%s covered", host) + } + } +} diff --git a/internal/tencent/tencenttest/fake.go b/internal/tencent/tencenttest/fake.go index cd40633..db954e5 100644 --- a/internal/tencent/tencenttest/fake.go +++ b/internal/tencent/tencenttest/fake.go @@ -33,6 +33,7 @@ type Record = tencent.Record type Domain struct { Zone, Name, Status, Cname, Origin, Protocol string CertMode, CertStatus string + CertIDs []string // mode sslcert: the certificates from SSL } // Fake is the running fake. @@ -340,12 +341,18 @@ func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) { continue } var certs []map[string]string - if d.CertMode == "eofreecert" { + switch d.CertMode { + case "eofreecert": certs = []map[string]string{{"CertId": "eofree-1", "Type": "managed", "Status": d.CertStatus, "ExpireTime": time.Now().Add(80 * 24 * time.Hour).UTC().Format(time.RFC3339)}} if d.CertStatus == "applying" { d.CertStatus = "deployed" // issued by the next look } + case "sslcert": + for _, id := range d.CertIDs { + certs = append(certs, map[string]string{"CertId": id, "Type": "upload", "Status": d.CertStatus, + "ExpireTime": time.Now().Add(80 * 24 * time.Hour).UTC().Format(time.RFC3339)}) + } } out = append(out, map[string]any{ "ZoneId": d.Zone, "DomainName": d.Name, "DomainStatus": d.Status, "Cname": d.Cname, @@ -406,9 +413,23 @@ func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) { for _, name := range strs("Hosts") { for _, d := range f.Domains { if d.Name == name { - d.CertMode, d.CertStatus = str("Mode"), "" - if d.CertMode == "eofreecert" { + d.CertMode, d.CertStatus, d.CertIDs = str("Mode"), "", nil + switch d.CertMode { + case "eofreecert": d.CertStatus = "applying" + case "sslcert": + if list, _ := in["ServerCertInfo"].([]any); len(list) > 0 { + for _, item := range list { + if m, _ := item.(map[string]any); m != nil { + if id, _ := m["CertId"].(string); id != "" { + d.CertIDs = append(d.CertIDs, id) + } + } + } + } + if len(d.CertIDs) > 0 { + d.CertStatus = "deployed" + } } } } diff --git a/internal/webui/static/app.js b/internal/webui/static/app.js index 354e9d2..3251e08 100644 --- a/internal/webui/static/app.js +++ b/internal/webui/static/app.js @@ -2216,7 +2216,16 @@ const DnsPage = { } // One click: a name to a server, an IP or a host, through EdgeOne if wanted. - const quick = reactive({ open: false, sub: '', target: 'server', serverId: 0, value: '', edgeone: false, https: true, protocol: 'HTTP', area: 'mainland' }); + const quick = reactive({ open: false, sub: '', target: 'server', serverId: 0, value: '', edgeone: false, https: true, cert: '', protocol: 'HTTP', area: 'mainland' }); + // Certificates in Tencent Cloud's SSL 证书, to pick one instead of a free one. + const certOptions = ref([]), certsLoading = ref(false), certsError = ref(''), certsLoaded = ref(false); + async function loadCerts() { + if (provider.value !== 'dnspod' || certsLoading.value || certsLoaded.value) return; + certsLoading.value = true; certsError.value = ''; + try { certOptions.value = await api('GET', '/api/certificates/tencent'); certsLoaded.value = true; } + catch (e) { certsError.value = e.message; } + finally { certsLoading.value = false; } + } const originEdit = reactive({ open: false, sub: '', protocol: 'HTTPS' }); function openOrigin(r) { formError.value = ''; Object.assign(originEdit, { open: true, sub: r.name, protocol: 'HTTPS' }); } function submitOrigin() { propose({ op: 'eo_origin', sub: originEdit.sub, protocol: originEdit.protocol }); } @@ -2224,15 +2233,22 @@ const DnsPage = { formError.value = ''; const s = props.servers[0]; Object.assign(quick, { open: true, sub: '', target: s ? 'server' : 'ip', serverId: s ? s.id : 0, value: '', - edgeone: !!(zone.value && zone.value.type === 'partial' && eoUsable.value), https: true, protocol: 'HTTP', area: 'mainland' }, pre || {}); + edgeone: !!(zone.value && zone.value.type === 'partial' && eoUsable.value), https: true, cert: '', protocol: 'HTTP', area: 'mainland' }, pre || {}); + loadCerts(); } function submitQuick() { propose({ op: 'quick', sub: quick.sub.trim() || '@', target: quick.target, serverId: quick.serverId, value: quick.value.trim(), - edgeone: quick.edgeone, https: quick.https, protocol: quick.protocol, area: quick.area }); + edgeone: quick.edgeone, https: quick.https, cert: quick.edgeone && quick.https ? quick.cert : '', protocol: quick.protocol, area: quick.area }); } // An error is about what was sent; editing the form clears it. watch(() => JSON.stringify([editor, quick, originEdit]), () => { if (!planning.value) formError.value = ''; }); const quickName = computed(() => ((quick.sub.trim() || '@') === '@' ? '' : quick.sub.trim() + '.') + domain.value); + // Only the certificates that can serve this name, newest first. + const certChoices = computed(() => certOptions.value.filter(c => c.usable && certCovers(c.names, quickName.value))); + const certPicked = computed(() => certOptions.value.find(c => c.id === quick.cert) || null); + const certNotListed = computed(() => !!(quick.cert && certPicked.value && !certChoices.value.some(c => c.id === quick.cert))); + const certWrongName = computed(() => !!(quick.cert && certPicked.value && !certCovers(certPicked.value.names, quickName.value))); + const certLabel = c => `${c.alias ? c.alias + ' · ' : ''}${c.names.join('、')}(${c.expires ? c.expires + ' 到期' : c.status}${c.autoRenew ? ',腾讯云托管续期' : ''})`; const del = r => propose({ op: 'delete', id: r.id, rid: r.rid }); const toggle = r => propose({ op: 'status', id: r.id, rid: r.rid, status: r.enabled ? 'disable' : 'enable' }); @@ -2244,6 +2260,7 @@ const DnsPage = { return { RECORD_TYPES, EO_AREAS, VALUE_HINT, domains, domainsLoaded, loadDomains, eoError, domain, data, loading, error, q, typeFilter, plan, planning, lines, formError, current, zone, eoUsable, shown, load, planDone, closePlan, editor, openEditor, editorTTLs, submitEditor, quick, openQuick, submitQuick, quickName, originEdit, openOrigin, submitOrigin, + certChoices, certPicked, certNotListed, certWrongName, certLabel, certsLoading, certsError, del, toggle, eoPoint, eoOff, eoOn, canEO, ttlText, planServerName, anyCloud, both, listErrors, provider, pick, dkey }; }, template: ` @@ -2310,7 +2327,7 @@ const DnsPage = { 已暂停 DNSPod 自带 — @@ -2371,13 +2388,26 @@ const DnsPage = { 服务器上这个网站没有证书就选 HTTP。 - + +
证书 + + 自动续签;要等解析生效后才申请得下来。 + 用腾讯云 SSL 证书里已有的证书,不会申请新证书;{{ certPicked.autoRenew ? '腾讯云托管会续期' : '到期前要自己续签或换一张' }}{{ certPicked.expires ? ',' + certPicked.expires + ' 到期' : '' }}。 + 正在读腾讯云 SSL 证书…… + 读不到腾讯云 SSL 证书:{{ certsError }}。仍然可以申请 EdgeOne 免费证书。 + 腾讯云 SSL 证书里没有包含 {{ quickName }} 的可用证书;可以在「证书」页申请一张,或者用上面的免费证书。 + 选中的证书不包含 {{ quickName }},请重新选一张。 +
{{ quickName }} → {{ quick.target === 'server' ? ((servers.find(s => s.id === quick.serverId) || {}).host || '') : (quick.value || '…') }}{{ quick.edgeone ? '(经过 EdgeOne)' : '' }}
{{ formError }}
-
+