Files
-Fonu/internal/api/cert_handler.go
T
mrlee dda0eb876e
CI / verify (push) Canceled after 0s
feat(cert,scripts,docs): Linux 原生安装包与证书申请三处修复(DNS 解析器 / Buypass 下线 / ZeroSSL 报错与密钥脱敏)(v1.2.13)
- Linux 安装包:scripts/build-linux.ps1 交叉编译出 fonu-<版本>-linux-{amd64,arm64}.tar.gz(二进制 + migrations + systemd 安装脚本 + 运行时资源 + sha256),scripts/fonu-install.sh 负责装机(/opt/fonu、/var/lib/fonu、/etc/fonu/fonu.env 首次生成随机会话密钥)并新增 --reset-admin;装完直接打印初始管理员口令

- 证书 DNS-01:lego 默认读系统 resolv.conf(容器里是 Docker 的 127.0.0.11,不返回权威 NS)导致 could not determine authoritative nameservers;现显式传 AddRecursiveNameservers,默认 223.5.5.5/1.1.1.1/8.8.8.8,设置项 acme_dns_resolvers 可覆盖(system = 回到系统解析器)

- Buypass 已停止签发(2025-10-15)并终止服务(2026-04-15):从可选 CA 移除,历史记录仍可展示,直接提交会被拦下并给出中文原因

- ZeroSSL:422 时带出 error.type/error.code(非 JSON 退回截断正文);同时修掉网络错误把含 access_key 的完整 URL 写进任务日志与 app.log 的密钥泄露

- 测试:解析器列表解析 3 例 + ZeroSSL EAB 4 例(含不泄露密钥断言);gofmt / go build / go vet / go test 全绿
2026-09-27 10:10:15 +08:00

159 lines
4.7 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 抦下)。
{"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)
}