Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)。左边是所有存储桶和它们的访问权限,公开读写的标红。选中一个桶:

Expand Down
6 changes: 3 additions & 3 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 证书的自动续签 | 改回原来的设置 |
Expand Down Expand Up @@ -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(已完成)

Expand Down
19 changes: 1 addition & 18 deletions internal/actions/cdn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...), "、"))
}
}
Expand All @@ -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 "启用"
Expand Down
93 changes: 76 additions & 17 deletions internal/actions/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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"], ",")
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/actions/cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"
"time"

"github.com/bocmiao/CloudConsoleWithAI/internal/tencent"
"github.com/bocmiao/CloudConsoleWithAI/internal/tencent/tencenttest"
)

Expand Down Expand Up @@ -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()
Expand Down
17 changes: 14 additions & 3 deletions internal/actions/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading