diff --git a/CHANGELOG.md b/CHANGELOG.md index 22410bb..3729aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to this project will be documented in this file. +## [v1.2.14] - 2026-09-27 + +### 新增 + +- **证书颁发机构:LiteSSL 替换 Buypass**。LiteSSL(亚数 TrustAsia 的公益免费 DV 证书,90 天,官方支持单域名 / 通配符 / 多域名)已接入,ACME 目录 `https://acme.litessl.com/acme/v2/directory`。该目录里 `meta.externalAccountRequired = true`,所以**强制 EAB**:在 FreeSSL 平台「证书自动化 → EAB 管理」创建后,把 Kid 与 HMAC 填进设置即可(不需要像 ZeroSSL 那样用 API Key 去换取)。EAB HMAC 只回掩码。 + Buypass 保留常量与展示元数据(历史证书记录仍能正常显示),但不再可选,直接提交会被拦下并提示替代项。域名若有 CAA 记录,需允许 `trustasia.com`。 + +### 修复 + +- **frps 安装一直报 HTTP 404**:frp 官方的校验文件名始终是 `frp_sha256_checksums.txt`(不带版本号,v0.52.3 / v0.58.1 / v0.62.1 / v0.71.0 实测均如此),而代码拼的是 `frp_<版本>_sha256sums.txt`——任何版本、任何网络环境下都装不上。现改为按候选名依次尝试(旧名保留兜底),失败信息里列出尝试过的文件名。 + **同一条 bug 也在本机下载 frpc 的路径上,一并修复**;SHA-256 校验逻辑本身一行未改。 +- **Nginx 探测两处(均在 agent)**: + - `nginxPath()` 探测不到时不再回落到配置里的裸名字 `nginx`:那会让「未安装」被误判为「配置校验未通过(nginx -t 执行失败)」,且「VPS 未安装 Nginx」告警与一键安装按钮**永远不会出现**。 + - vhost 目录探测不再要求被加载文件以 `.conf` 结尾:Debian/Ubuntu 的 `sites-enabled/default` 不带后缀、而 `nginx.conf` 里是 `include .../sites-enabled/*`,过滤掉它之后只剩主配置目录可试,报「在 1 个被加载目录中均无法放置完整 server 配置」;现在失败信息会带上最后一次的具体原因与常见显式指定目录。 +- **前端申请证书弹窗的颁发机构卡片**改为前端可控清单:原先硬编码为 Let's Encrypt / ZeroSSL / Buypass,后端 `/api/certs/options` 的结果不参与渲染,导致后端增删 CA 时弹窗不变(本次 LiteSSL 替换就是这幺被发现的)。现在卡片为 Let's Encrypt / ZeroSSL / LiteSSL。 + +### 测试 + +- `internal/frp`:校验文件候选名顺序、sha256sum 格式解析(含 `*` 前缀与不存在的文件负例) +- `internal/acme`:LiteSSL 目录地址 / 展示名 / 别名归一化、通配符与多域名不被拦、Buypass 停用提示里含替代项 + +### 变更 + +- `AgentVersion` 0.12.4 → **0.12.7**(0.12.5 修 frps 校验文件名;0.12.6 修 nginxPath;0.12.7 修 vhost 目录探测),双架构内嵌二进制已重编;**VPS 上需点一次「升级 Agent」** +- 版本号提升到 1.2.14;`web/package.json` 同步对齐 + ## [v1.2.13] - 2026-09-27 ### 新增 diff --git a/VERSION b/VERSION index 0b1f1ed..fd9d1a5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.13 +1.2.14 diff --git a/internal/acme/ca.go b/internal/acme/ca.go index 11ee273..adff462 100644 --- a/internal/acme/ca.go +++ b/internal/acme/ca.go @@ -12,13 +12,14 @@ const ( CALetsEncryptStaging = "letsencrypt-staging" CAZeroSSL = "zerossl" CABuypass = "buypass" + // CALiteSSL 亚数 TrustAsia 的公益免费证书(90 天,支持单域名/通配符/多域名,强制 EAB) + CALiteSSL = "litessl" ) // caRetired 记录已停止签发的 CA:仍然认识这些值(历史证书记录里可能存着,用于展示与清理), // 但不允许再用来申请——否则用户只会看到一个难懂的 ACME 报错而不是「这家 CA 不干了」。 -// 否则用户只会看到一个难懂的 ACME 报错。 var caRetired = map[string]string{ - CABuypass: "Buypass 已于 2025-10-15 停止签发证书(ACME 服务 2026-04-15 终止),请改用 Let's Encrypt 或 ZeroSSL", + CABuypass: "Buypass 已于 2025-10-15 停止签发证书(ACME 服务 2026-04-15 终止),请改用 Let's Encrypt、LiteSSL 或 ZeroSSL", } // CAUnavailableReason 返回该 CA 已停用的原因;可用的 CA 返回空字符串。 @@ -29,6 +30,8 @@ func CAUnavailableReason(ca string) string { const ( zeroSSLDirectoryURL = "https://acme.zerossl.com/v2/DV90" buypassDirectoryURL = "https://api.buypass.com/acme/directory" + // liteSSLDirectoryURL 实测 2026-09-27 可访问,目录里 meta.externalAccountRequired = true + liteSSLDirectoryURL = "https://acme.litessl.com/acme/v2/directory" ) func NormalizeCA(ca string) string { @@ -41,6 +44,8 @@ func NormalizeCA(ca string) string { return CAZeroSSL case CABuypass, "buypass-go": return CABuypass + case CALiteSSL, "trustasia": + return CALiteSSL default: return ca } @@ -56,6 +61,8 @@ func DirectoryURL(ca string) (string, error) { return zeroSSLDirectoryURL, nil case CABuypass: return buypassDirectoryURL, nil + case CALiteSSL: + return liteSSLDirectoryURL, nil default: return "", fmt.Errorf("不支持的颁发机构:%s", ca) } @@ -71,6 +78,8 @@ func CALabel(ca string) string { return "ZeroSSL" case CABuypass: return "Buypass" + case CALiteSSL: + return "LiteSSL" default: if ca == "" { return "手动导入" @@ -84,6 +93,8 @@ func ValidateCA(ca string) error { return err } +// ValidateCADomains 校验各 CA 自己的限制。 +// LiteSSL 官方支持单域名 / 通配符 / 多域名,因此这里不额外限制。 func ValidateCADomains(ca string, domains []string) error { switch NormalizeCA(ca) { case CABuypass: diff --git a/internal/acme/litessl_test.go b/internal/acme/litessl_test.go new file mode 100644 index 0000000..1866568 --- /dev/null +++ b/internal/acme/litessl_test.go @@ -0,0 +1,50 @@ +package acme + +import ( + "strings" + "testing" +) + +// LiteSSL 接替 Buypass 的位置:目录地址实测可用,且强制 EAB。 +func TestLiteSSLDirectoryAndLabel(t *testing.T) { + url, err := DirectoryURL(CALiteSSL) + if err != nil { + t.Fatalf("LiteSSL 目录地址解析失败:%v", err) + } + if url != "https://acme.litessl.com/acme/v2/directory" { + t.Fatalf("LiteSSL 目录地址不对:%q", url) + } + if label := CALabel(CALiteSSL); label != "LiteSSL" { + t.Fatalf("LiteSSL 展示名不对:%q", label) + } + // 别名与归一化 + if got := NormalizeCA("trustasia"); got != CALiteSSL { + t.Fatalf("别名 trustasia 应归一化为 %s,实际 %q", CALiteSSL, got) + } + if reason := CAUnavailableReason(CALiteSSL); reason != "" { + t.Fatalf("LiteSSL 是可用的 CA,不该有停用原因:%q", reason) + } +} + +// LiteSSL 支持通配符与多域名,因此 ValidateCADomains 不应拦它。 +func TestValidateCADomainsLiteSSLAllowsWildcard(t *testing.T) { + domains := []string{"*.36800.cc", "a.com", "b.com", "c.com", "d.com", "e.com", "f.com"} + if err := ValidateCADomains(CALiteSSL, domains); err != nil { + t.Fatalf("LiteSSL 应允许通配符与多域名:%v", err) + } +} + +// Buypass 保留在停用表里(历史记录要能展示),但提示里应给出可用的替代。 +func TestBuypassIsRetiredWithAlternatives(t *testing.T) { + reason := CAUnavailableReason(CABuypass) + if reason == "" { + t.Fatal("Buypass 应仍被标记为已停用") + } + if !strings.Contains(reason, "LiteSSL") { + t.Fatalf("停用提示里应包含 LiteSSL 这个替代项:%q", reason) + } + // 旧记录仍能正常取到目录地址(不报「不支持的颁发机构」) + if _, err := DirectoryURL(CABuypass); err != nil { + t.Fatalf("历史 Buypass 记录的目录地址仍应可解析:%v", err) + } +} diff --git a/internal/acme/service.go b/internal/acme/service.go index bf3f3fe..70c0f80 100644 --- a/internal/acme/service.go +++ b/internal/acme/service.go @@ -416,6 +416,27 @@ func (s *Service) registerACMEAccount(ctx context.Context, client *lego.Client, Kid: kid, HmacEncoded: hmac, }) + case CALiteSSL: + // LiteSSL 的目录里 meta.externalAccountRequired = true:必须带 EAB。 + // 与 ZeroSSL 不同,不经过厂商 API 换取,而是用户在 FreeSSL 平台自建 EAB 后直接填 kid/hmac。 + kid, err := s.settings.Get(ctx, settings.KeyLiteSSLEABKid) + if err != nil { + return nil, err + } + hmac, err := s.settings.Get(ctx, settings.KeyLiteSSLEABHMAC) + if err != nil { + return nil, err + } + kid = strings.TrimSpace(kid) + hmac = strings.TrimSpace(hmac) + if kid == "" || hmac == "" { + return nil, fmt.Errorf("LiteSSL 要求 EAB 凭据:请在设置里填写 EAB Kid 与 EAB HMAC(在 FreeSSL 的「证书自动化 → EAB 管理」创建后原样粘贴)") + } + return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{ + TermsOfServiceAgreed: true, + Kid: kid, + HmacEncoded: hmac, + }) default: return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) } diff --git a/internal/agent/server.go b/internal/agent/server.go index eb2f547..e22f61f 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -43,7 +43,12 @@ import ( // 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。 // 0.12.3:frps 日志尾巴改为最近 20 行、最新在前,并用 short-iso 时间戳(默认格式不带年份、分不清哪一天)。 // 0.12.4:日志行重写成「2026-09-22 18:43:34 进程: 消息」,去掉 T 分隔、时区偏移与每行重复的主机名。 -const AgentVersion = "0.12.4" +// 0.12.5:修正 frps 官方校验文件名(frp 一直叫 frp_sha256_checksums.txt,原代码拼的是 frp_<版本>_sha256sums.txt,导致首次安装报 HTTP 404)。 +// 0.12.6:nginxPath 探测不到时返回空串(原来回落到配置里的裸名字 "nginx"), +// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。 +// 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带), +// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。 +const AgentVersion = "0.12.7" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -259,6 +264,25 @@ var frpsDownloadProxies = map[string]struct{}{ const frpsReleaseBaseURL = "https://github.com/fatedier/frp/releases/download/" +// fetchChecksumForCandidates 依次尝试官方校验文件名的候选。 +// frp 的校验文件一直叫 frp_sha256_checksums.txt(不带版本号),旧名字保留兜底, +// 避开上游改名后整个安装流程直接 404。 +func fetchChecksumForCandidates(releaseURL, version, asset string) (string, error) { + candidates := []string{ + "frp_sha256_checksums.txt", + "frp_" + version + "_sha256sums.txt", + } + var lastErr error + for _, name := range candidates { + sum, err := fetchChecksumFor(releaseURL+name, asset) + if err == nil { + return sum, nil + } + lastErr = err + } + return "", fmt.Errorf("%v(已尝试:%s)", lastErr, strings.Join(candidates, "、")) +} + // installFRPS 下载指定版本的 frp release、安装 frps 二进制并注册 systemd 服务(幂等,重复调用=升级)。 func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) { var req frpsInstallRequest @@ -292,7 +316,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) { return } // 先取官方校验文件,再下载安装包并按 SHA-256 校验:避免装上来路不明的 frps 可执行文件 - wantSum, err := fetchChecksumFor(releaseURL+"frp_"+version+"_sha256sums.txt", asset) + wantSum, err := fetchChecksumForCandidates(releaseURL, version, asset) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "获取 frps 官方校验信息失败: " + err.Error()}) return @@ -646,9 +670,11 @@ func detectNginxConfDir(binary string) (string, error) { line = strings.TrimSpace(line) if value, found := strings.CutPrefix(line, "# configuration file "); found { p := strings.TrimSuffix(strings.TrimSpace(value), ":") - if strings.HasSuffix(p, ".conf") { - loadedDirs[filepath.Dir(p)] = true - } + // 不要求 .conf 后缀:Debian/Ubuntu 的 sites-enabled/default 本身不带后缀, + // 而 nginx.conf 里是 include /etc/nginx/sites-enabled/*(也无后缀限制); + // 只认 .conf 会把这个真正该放 vhost 的目录漏掉,只剩主配置目录可试, + // 而主配置目录里的新文件并不被 include(表现为「均无法放置完整 server 配置」)。 + loadedDirs[filepath.Dir(p)] = true } } delete(loadedDirs, ".") @@ -667,13 +693,16 @@ func detectNginxConfDir(binary string) (string, error) { } return dirs[i] < dirs[j] }) + var lastReason string for _, dir := range dirs { if err := os.MkdirAll(dir, 0o755); err != nil { + lastReason = fmt.Sprintf("%s: %v", dir, err) continue } probe := filepath.Join(dir, "fonu-probe.conf") probeBody := "# Managed by fonu-agent. Do not edit.\nserver {\n listen 127.0.0.1:1;\n server_name fonu-probe.invalid;\n}\n" if err := atomicWrite(probe, []byte(probeBody), 0o644); err != nil { + lastReason = fmt.Sprintf("%s: %v", dir, err) continue } // 两级验证:-t 仅验证语法,不保证文件被 include 加载(主配置目录等无 include 的目录也能通过)。 @@ -685,6 +714,7 @@ func detectNginxConfDir(binary string) (string, error) { return dir, nil } // -t 通过但 -T 不含探针:该目录不被 include 加载,删除探针换下一个 + lastReason = fmt.Sprintf("%s: 目录未被 include 加载(nginx -T 输出中无探针文件)", dir) _ = os.Remove(probe) continue } @@ -692,13 +722,19 @@ func detectNginxConfDir(binary string) (string, error) { // 若 -t 失败信息指向历史残留的 fonu-*.conf,也一并清理(仅限 Agent 生成的文件)。 _ = os.Remove(probe) if tOut, tErr := exec.Command(nginx, "-t").CombinedOutput(); tErr != nil { + lastReason = fmt.Sprintf("%s: nginx -t 失败:%s", dir, tailText(string(tOut), 300)) cleanupLegacyFonuConf(string(tOut)) if err := runCommand(nginx, "-t"); err == nil { continue // 清理残留后配置恢复可用,换下一个目录 } + } else { + lastReason = fmt.Sprintf("%s: 探针写入后 nginx -t 未通过", dir) } } - return "", fmt.Errorf("在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);可用 FONU_AGENT_NGINX_CONF_DIR 显式指定", len(dirs), strings.Join(dirs, ", ")) + if lastReason == "" { + lastReason = "没有可尝试的目录" + } + return "", fmt.Errorf("在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);最后一次失败原因:%s;也可用 FONU_AGENT_NGINX_CONF_DIR 显式指定(Debian/Ubuntu 通常是 /etc/nginx/conf.d 或 /etc/nginx/sites-enabled)", len(dirs), strings.Join(dirs, ", "), lastReason) } // dirHasVhostConf 判断目录下是否已有含 server_name 的 .conf(HTTP vhost 语义)。 @@ -1178,11 +1214,18 @@ func (s *Server) validateNginx() error { } func (s *Server) reloadNginx() error { - return runCommand(s.nginxPath(), "-s", "reload") + nginx := s.nginxPath() + if nginx == "" { + return errors.New("未找到 nginx 可执行文件:请先在「公网反代」页安装 Nginx,或用 FONU_AGENT_NGINX_BIN 指定路径") + } + return runCommand(nginx, "-s", "reload") } -// nginxPath 返回可用的 nginx 绝对路径:配置的路径可用则用之;否则探测常见安装位置 +// nginxPath 返回**可用的** nginx 可执行路径:配置的路径确实可用才用它,否则探测常见安装位置 // (宝塔的 nginx 不在 systemd PATH 里,直接用名字会 exec 失败)。 +// 探测不到时返回空串。注意:不要回落到配置里的裸名字(如 "nginx"), +// 那会让 probeNginx 的 Missing 判定永远不成立——「未安装」会被当成「配置校验失败」 +// (页面显示 nginx -t 执行失败),且一键安装入口永远不出现。 func (s *Server) nginxPath() string { if p := strings.TrimSpace(s.cfg.NginxBinary); p != "" { if filepath.IsAbs(p) { @@ -1198,7 +1241,7 @@ func (s *Server) nginxPath() string { return candidate } } - return s.cfg.NginxBinary + return "" } // probeNginx 真实探测 Nginx 可用性:解析可执行文件并执行 nginx -t;结果缓存 30 秒。 diff --git a/internal/api/cert_handler.go b/internal/api/cert_handler.go index 6457972..dd755c3 100644 --- a/internal/api/cert_handler.go +++ b/internal/api/cert_handler.go @@ -30,8 +30,9 @@ func (h *CertHandler) Options(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, []map[string]string{ {"value": acme.CALetsEncrypt, "label": acme.CALabel(acme.CALetsEncrypt)}, {"value": acme.CAZeroSSL, "label": acme.CALabel(acme.CAZeroSSL)}, - // Buypass 已于 2025-10-15 停止签发、2026-04-15 终止 ACME 服务, - // 不再列为可选 CA(历史记录仍能展示,直接提交会被 CAUnavailableReason 抦下)。 + // Buypass 已于 2025-10-15 停止签发、2026-04-15 终止 ACME 服务,不再列为可选 CA + // (历史记录仍能展示,直接提交会被 CAUnavailableReason 抦下);LiteSSL 接替其位置。 + {"value": acme.CALiteSSL, "label": acme.CALabel(acme.CALiteSSL)}, {"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)}, }) } diff --git a/internal/api/settings_handler.go b/internal/api/settings_handler.go index 1928a4b..1a7b756 100644 --- a/internal/api/settings_handler.go +++ b/internal/api/settings_handler.go @@ -55,6 +55,8 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) { settings.KeyACMECA: true, settings.KeyZeroSSLAPIKey: true, settings.KeyACMEDNSResolvers: true, + settings.KeyLiteSSLEABKid: true, + settings.KeyLiteSSLEABHMAC: true, settings.KeyNotifyWebhookURL: true, settings.KeyNotifyType: true, settings.KeyNotifyEmailJSON: true, diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index 8574919..4927d02 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index 39290c3..3ca33ba 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/frp/checksum_test.go b/internal/frp/checksum_test.go new file mode 100644 index 0000000..9257dfe --- /dev/null +++ b/internal/frp/checksum_test.go @@ -0,0 +1,42 @@ +package frp + +import ( + "strings" + "testing" +) + +// 官方校验文件名不带版本号;这里钉住候选顺序,避免以后又被改回不存在的名字。 +func TestChecksumAssetCandidates(t *testing.T) { + got := checksumAssetCandidates("0.71.0") + if len(got) < 2 { + t.Fatalf("应至少有一个候选与一个兜底,实际 %v", got) + } + if got[0] != "frp_sha256_checksums.txt" { + t.Fatalf("首选应为官方现行名字,实际 %q", got[0]) + } + if got[1] != "frp_0.71.0_sha256sums.txt" { + t.Fatalf("兜底名字应带版本号,实际 %q", got[1]) + } +} + +// 解析真实格式: <文件名>(也兼容 * 前缀与多余空白) +func TestFindChecksumLine(t *testing.T) { + content := []byte(strings.Join([]string{ + "845b486c63686990e671f13cc5e3bd130ce7be659ab3c6f043008353451858cb frp_0.71.0_android_arm64.tar.gz", + "84f27e39f11169f7adcef8e8b70c9329de17747b1f14dad9fb95eef5682ea716 frp_0.71.0_linux_amd64.tar.gz", + "96b8a4a09d7cd6a5ea0e1f9f0b1e4a35b0e0f0b4a4f7c0b0e0f0b4a4f7c0b0e0 *frp_0.71.0_linux_arm64.tar.gz", + "", + }, "\n")) + + sum, ok := findChecksumLine(content, "frp_0.71.0_linux_amd64.tar.gz") + if !ok || sum != "84f27e39f11169f7adcef8e8b70c9329de17747b1f14dad9fb95eef5682ea716" { + t.Fatalf("未解析出 linux_amd64 的哈希:%q ok=%v", sum, ok) + } + sum, ok = findChecksumLine(content, "frp_0.71.0_linux_arm64.tar.gz") + if !ok || sum != "96b8a4a09d7cd6a5ea0e1f9f0b1e4a35b0e0f0b4a4f7c0b0e0f0b4a4f7c0b0e0" { + t.Fatalf("带 * 前缀的文件名应能匹配:%q ok=%v", sum, ok) + } + if _, ok := findChecksumLine(content, "frp_0.71.0_windows_amd64.zip"); ok { + t.Fatal("不存在的文件不应匹配到哈希") + } +} diff --git a/internal/frp/runtime.go b/internal/frp/runtime.go index bc9ec17..a46bfd6 100644 --- a/internal/frp/runtime.go +++ b/internal/frp/runtime.go @@ -168,21 +168,46 @@ func downloadOfficialBinary(ctx context.Context, frpDir string, release Release, return extractClientBinary(archivePath, frpDir, release) } +// checksumAssetNames 官方校验文件名候选(按优先级)。 +// frp 从早期版本起就一直是 frp_sha256_checksums.txt(不带版本号), +// 下面保留一个旧名字兼底,避开上游再次改名时整条下载链路直接 404。 +func checksumAssetCandidates(version string) []string { + return []string{ + "frp_sha256_checksums.txt", + "frp_" + version + "_sha256sums.txt", + } +} + func fetchChecksum(ctx context.Context, release Release) (string, error) { version := strings.TrimPrefix(release.Version, "v") - name := "frp_" + version + "_sha256sums.txt" - url := officialAssetURL(release.Version, name) - data, err := fetchBytes(ctx, url, 1<<20) - if err != nil { - return "", fmt.Errorf("下载 FRP 官方校验文件失败:%w", err) + candidates := checksumAssetCandidates(version) + var lastErr error + for _, name := range candidates { + data, err := fetchBytes(ctx, officialAssetURL(release.Version, name), 1<<20) + if err != nil { + lastErr = err + continue + } + if sum, ok := findChecksumLine(data, release.AssetName); ok { + return sum, nil + } + lastErr = fmt.Errorf("FRP 官方校验文件 %s 中未找到 %s", name, release.AssetName) } + if lastErr == nil { + lastErr = fmt.Errorf("没有可用的官方校验文件") + } + return "", fmt.Errorf("下载 FRP 官方校验文件失败:%v(已尝试:%s)", lastErr, strings.Join(candidates, "、")) +} + +// findChecksumLine 解析 sha256sum 格式( <文件名>,文件名可能带 * 前缀)。 +func findChecksumLine(data []byte, assetName string) (string, bool) { for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) - if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == release.AssetName { - return fields[0], nil + if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == assetName { + return fields[0], true } } - return "", fmt.Errorf("FRP 官方校验文件中未找到 %s", release.AssetName) + return "", false } func downloadArchive(ctx context.Context, frpDir string, release Release, checksum, proxy string, report downloadReporter) (string, error) { diff --git a/internal/notify/store.go b/internal/notify/store.go index 829bee3..3210b13 100644 --- a/internal/notify/store.go +++ b/internal/notify/store.go @@ -474,6 +474,10 @@ func SanitizeSettings(values map[string]string) { if values[settings.KeyZeroSSLAPIKey] != "" { values[settings.KeyZeroSSLAPIKey] = MaskedSecret } + // LiteSSL 的 EAB HMAC 同理:属于证书申请凭据,只回掩码(EAB Kid 不是秘密,照常回显) + if values[settings.KeyLiteSSLEABHMAC] != "" { + values[settings.KeyLiteSSLEABHMAC] = MaskedSecret + } } func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 82c174f..e2e2588 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -18,7 +18,10 @@ const ( KeyACMECA = "acme_ca" KeyZeroSSLAPIKey = "zerossl_api_key" // KeyACMEDNSResolvers DNS-01 传播检查用的递归解析器(逗号分隔;system = 用系统解析器) - KeyACMEDNSResolvers = "acme_dns_resolvers" + KeyACMEDNSResolvers = "acme_dns_resolvers" + // LiteSSL 强制 EAB:kid 与 hmac 在 FreeSSL 的「证书自动化 → EAB 管理」创建 + KeyLiteSSLEABKid = "litessl_eab_kid" + KeyLiteSSLEABHMAC = "litessl_eab_hmac" KeyNotifyWebhookURL = "notify_webhook_url" KeyNotifyType = "notify_type" KeyNotifyEmailJSON = "notify_email_json" diff --git a/web/package.json b/web/package.json index d70f4e6..d1b73e2 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.2.13", + "version": "1.2.14", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/views/CertificatesView.vue b/web/src/views/CertificatesView.vue index 5d090ea..57263ed 100644 --- a/web/src/views/CertificatesView.vue +++ b/web/src/views/CertificatesView.vue @@ -216,8 +216,12 @@

使用 ZeroSSL 需在「设置」中配置 API Key

+

+ LiteSSL(亚数 TrustAsia)免费 DV 证书,支持单域名 / 通配符 / 多域名; + 首次使用需在设置里填 EAB 凭据(FreeSSL 平台「证书自动化 → EAB 管理」创建) +

- Buypass 已于 2025-10-15 停止签发证书,请改选 Let's Encrypt 或 ZeroSSL + Buypass 已于 2025-10-15 停止签发证书,请改选 Let's Encrypt 或 LiteSSL

@@ -486,7 +490,7 @@ const caOptions = ref([]) const applyCAOptions = [ { value: 'letsencrypt', label: "Let's Encrypt" }, { value: 'zerossl', label: 'ZeroSSL' }, - { value: 'buypass', label: 'Buypass' }, + { value: 'litessl', label: 'LiteSSL' }, ] const applyCA = ref('letsencrypt') const zerosslApiKey = ref('') @@ -547,7 +551,9 @@ const caCardMeta: Record = { letsencrypt: { title: "Let's Encrypt", sub: '免费 · 自动续期' }, 'letsencrypt-staging': { title: "Let's Encrypt 测试", sub: '仅用于验证流程' }, zerossl: { title: 'ZeroSSL', sub: '免费 · 稳定' }, - buypass: { title: 'Buypass', sub: '免费 · 备用' }, + // Buypass 已停服:仍保留元数据,历史证书记录要能正常显示 + buypass: { title: 'Buypass', sub: '已停止签发' }, + litessl: { title: 'LiteSSL', sub: '免费 · 国内根' }, } function providerLabel(v: string) { diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index d91438f..29a2859 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -69,7 +69,7 @@
ACME 邮箱
-

用于 Let's Encrypt / ZeroSSL 账户注册

+

用于 Let's Encrypt / ZeroSSL / LiteSSL 账户注册

+
+
LiteSSL EAB Kid
+ +

+ 只在用 LiteSSL 申请时必填。LiteSSL 的目录要求 EAB(externalAccountRequired), + 凭据在 FreeSSL 平台的「证书自动化 → EAB 管理」创建;EAB Kid 不是密钥,可回显。 +

+
+ @@ -1026,6 +1040,10 @@ const certThreshold = ref(30) const logRetention = ref(30) const acmeEmail = ref('') const acmeResolvers = ref('') +// LiteSSL 的 EAB 凭据:kid 不是秘密(可回显),hmac 是秘密(只回掩码、重输才提交) +const litesslEabKid = ref('') +const litesslEabHmac = ref('') +const litesslEabHmacConfigured = ref(false) const zerosslApiKey = ref('') const zerosslHasKey = ref(false) const trustedProxyEnabled = ref(false) @@ -1120,6 +1138,9 @@ function applySettingsToForm(settings: Record) { statusPageConfigured.value = statusPageCode.value === MASKED_SECRET acmeEmail.value = settings.acme_email ?? '' acmeResolvers.value = settings.acme_dns_resolvers ?? '' + litesslEabKid.value = settings.litessl_eab_kid ?? '' + litesslEabHmacConfigured.value = Boolean(settings.litessl_eab_hmac?.trim()) + litesslEabHmac.value = '' zerosslHasKey.value = Boolean(settings.zerossl_api_key?.trim()) zerosslApiKey.value = '' chinaCIDRHours.value = Number(settings.china_cidr_update_interval_hours ?? 24) @@ -1592,6 +1613,7 @@ function buildSavePayload() { log_retention_days: String(logRetention.value), acme_email: acmeEmail.value.trim(), acme_dns_resolvers: acmeResolvers.value.trim(), + litessl_eab_kid: litesslEabKid.value.trim(), trusted_proxy_json: buildTrustedProxyJSON(), global_ip_blacklist: buildGlobalIPListJSON(globalIPBlacklistText.value), global_ip_whitelist: buildGlobalWhitelistJSON(), @@ -1604,6 +1626,10 @@ function buildSavePayload() { if (zerosslKey) { payload.zerossl_api_key = zerosslKey } + const litesslHmac = litesslEabHmac.value.trim() + if (litesslHmac) { + payload.litessl_eab_hmac = litesslHmac + } return payload }