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 }}
-
+