CI / verify (push) Canceled after 0s
- frps 安装/升级一直 404:frp 官方校验文件名始终是 frp_sha256_checksums.txt(不带版本号),代码却拼 frp_<版本>_sha256sums.txt;现按候选名依次尝试并在失败时列出尝试项。同一条 bug 也存在于本机下载 frpc 的路径,一并修复(SHA-256 校验逻辑未改) - nginx 探测(agent):nginxPath() 不再回落到配置里的裸名字 nginx(否则「未安装」被误报为配置校验失败,且一键安装入口永不出现);vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带,导致只剩主配置目录可试,报「均无法放置完整 server 配置」),失败信息补上具体原因 - 证书 CA:LiteSSL(亚数 TrustAsia,90 天,支持单域名/通配符/多域名)替换 Buypass。LiteSSL 目录 meta.externalAccountRequired=true,强制 EAB——在 FreeSSL 创建后填 Kid/HMAC 即可,HMAC 只回掩码;Buypass 保留常量与展示元数据但不再可选 - 前端申请证书弹窗的颁发机构卡片改为前端可控清单(原先后端 /api/certs/options 的结果不参与渲染,后端改了弹窗不变) - 测试:internal/frp 校验文件候选名与 sha256sum 解析;internal/acme LiteSSL 目录/别名/通配符/Buypass 停用提示。gofmt / go build / go vet / go test / vue-tsc / vite build 全绿 - AgentVersion 0.12.4 → 0.12.7,双架构二进制已重编:VPS 上需点「升级 Agent」
160 lines
4.8 KiB
Go
160 lines
4.8 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/fonu/fonu/internal/acme"
|
|
)
|
|
|
|
type CertHandler struct {
|
|
svc *acme.Service
|
|
}
|
|
|
|
func NewCertHandler(svc *acme.Service) *CertHandler {
|
|
return &CertHandler{svc: svc}
|
|
}
|
|
|
|
func (h *CertHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
records, err := h.svc.List(r.Context())
|
|
if err != nil {
|
|
writeError(r, w, http.StatusInternalServerError, "读取证书失败")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, records)
|
|
}
|
|
|
|
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 抦下);LiteSSL 接替其位置。
|
|
{"value": acme.CALiteSSL, "label": acme.CALabel(acme.CALiteSSL)},
|
|
{"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)},
|
|
})
|
|
}
|
|
|
|
func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Domain string `json:"domain"`
|
|
DNSZone string `json:"dns_zone"`
|
|
DDNSConfigID int64 `json:"ddns_config_id"`
|
|
Domains []string `json:"domains"`
|
|
CA string `json:"ca"`
|
|
Email string `json:"email"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
|
return
|
|
}
|
|
domains := req.Domains
|
|
if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
|
|
domains = []string{req.Domain}
|
|
}
|
|
if req.DDNSConfigID <= 0 {
|
|
writeError(r, w, http.StatusBadRequest, "请选择 DNS 任务")
|
|
return
|
|
}
|
|
jobID, err := h.svc.StartApply(r.Context(), domains, req.CA, req.Email, req.DDNSConfigID)
|
|
if err != nil {
|
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
|
|
}
|
|
|
|
func (h *CertHandler) ApplyJobStream(w http.ResponseWriter, r *http.Request) {
|
|
jobID := strings.TrimSpace(r.PathValue("id"))
|
|
if jobID == "" {
|
|
writeError(r, w, http.StatusBadRequest, "无效的任务 ID")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
|
|
return
|
|
}
|
|
if err := h.svc.StreamJob(r.Context(), w, jobID, func() error {
|
|
flusher.Flush()
|
|
return nil
|
|
}); err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *CertHandler) Import(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Certificate string `json:"certificate"`
|
|
PrivateKey string `json:"private_key"`
|
|
CertPath string `json:"cert_path"`
|
|
KeyPath string `json:"key_path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
|
return
|
|
}
|
|
record, err := h.svc.Import(r.Context(), req.Certificate, req.PrivateKey, req.CertPath, req.KeyPath)
|
|
if err != nil {
|
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, record)
|
|
}
|
|
|
|
func (h *CertHandler) Download(w http.ResponseWriter, r *http.Request) {
|
|
domain := strings.TrimSpace(r.PathValue("domain"))
|
|
if domain == "" {
|
|
writeError(r, w, http.StatusBadRequest, "域名不能为空")
|
|
return
|
|
}
|
|
part := r.URL.Query().Get("part")
|
|
filename, contentType, data, err := h.svc.Download(r.Context(), domain, part)
|
|
if err != nil {
|
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
func (h *CertHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
domain := strings.TrimSpace(r.PathValue("domain"))
|
|
if domain == "" {
|
|
writeError(r, w, http.StatusBadRequest, "域名不能为空")
|
|
return
|
|
}
|
|
if err := h.svc.Delete(r.Context(), domain); err != nil {
|
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "证书已删除"})
|
|
}
|
|
|
|
func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Domain string `json:"domain"`
|
|
CA string `json:"ca"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
|
return
|
|
}
|
|
record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
|
|
if err != nil {
|
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, record)
|
|
}
|