diff --git a/README.md b/README.md
index f16b159..3f73d4c 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@
| **监控**:每分钟打开一次每个网站,每两分钟看一次服务器,打不开马上提醒 | **备份**:立即备份、每天自动备份(由 1Panel 在服务器上跑),一键恢复 |
|  |  |
-**解析**:DNSPod、阿里云云解析和 EdgeOne 放在一起管,哪些名字经过 EdgeOne、哪些还没解析过去一眼就能看到;一键解析能同时把加速域名、CNAME 和免费证书都配好。
+**解析**:DNSPod、阿里云云解析和 EdgeOne 放在一起管,哪些名字经过 EdgeOne、哪些还没解析过去一眼就能看到;一键解析能同时把加速域名、CNAME 和 HTTPS 都配好,证书可以申请 EdgeOne 免费证书,也可以直接选一张托管在腾讯云 SSL 证书里的。

@@ -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 自带
- 经过 EdgeOne