feat: 部署证书时支持开启 HTTPS 及一键签发证书泛域名 DNS 选择

This commit is contained in:
耗子
2026-03-08 00:27:55 +08:00
parent 2be464777b
commit 4a6fdc45d5
12 changed files with 197 additions and 34 deletions
+1 -1
View File
@@ -45,5 +45,5 @@ type CertRepo interface {
ObtainSelfSigned(id uint) error
Renew(id uint) (*acme.Certificate, error)
RefreshRenewalInfo(id uint) (mholtacme.RenewalInfo, error)
Deploy(ID, WebsiteID uint) error
Deploy(ID, WebsiteID uint, enableHTTPS bool) error
}
+1 -1
View File
@@ -49,5 +49,5 @@ type WebsiteRepo interface {
ResetConfig(id uint) error
UpdateStatus(id uint, status bool) error
UpdateCert(req *request.WebsiteUpdateCert) error
ObtainCert(ctx context.Context, id uint) error
ObtainCert(ctx context.Context, id uint, dnsID uint) error
}
+91 -14
View File
@@ -24,6 +24,8 @@ import (
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
"github.com/acepanel/panel/v3/pkg/webserver"
webservertypes "github.com/acepanel/panel/v3/pkg/webserver/types"
)
type certRepo struct {
@@ -207,15 +209,15 @@ func (r *certRepo) ObtainAuto(id uint) (*acme.Certificate, error) {
} else {
if cert.Website == nil {
return nil, errors.New(r.t.Get("this certificate is not associated with a website and cannot be obtained. You can try to obtain it manually"))
} else {
for _, domain := range cert.Domains {
if strings.Contains(domain, "*") {
return nil, errors.New(r.t.Get("wildcard domains cannot use HTTP verification"))
}
}
conf := fmt.Sprintf("%s/sites/%s/config/site/001-acme.conf", app.Root, cert.Website.Name)
client.UseHTTP(conf, webServer)
}
hasWildcard := slices.ContainsFunc(cert.Domains, func(d string) bool {
return strings.Contains(d, "*")
})
if hasWildcard {
return nil, errors.New(r.t.Get("wildcard domains cannot use HTTP verification"))
}
conf := fmt.Sprintf("%s/sites/%s/config/site/001-acme.conf", app.Root, cert.Website.Name)
client.UseHTTP(conf, webServer)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
@@ -234,7 +236,7 @@ func (r *certRepo) ObtainAuto(id uint) (*acme.Certificate, error) {
}
if cert.Website != nil {
return &ssl, r.Deploy(cert.ID, cert.WebsiteID)
return &ssl, r.Deploy(cert.ID, cert.WebsiteID, false)
}
if err = r.runScript(cert); err != nil {
@@ -285,7 +287,7 @@ func (r *certRepo) ObtainSelfSigned(id uint) error {
}
if cert.Website != nil {
return r.Deploy(cert.ID, cert.WebsiteID)
return r.Deploy(cert.ID, cert.WebsiteID, false)
}
if err = r.runScript(cert); err != nil {
@@ -348,7 +350,7 @@ func (r *certRepo) Renew(id uint) (*acme.Certificate, error) {
}
if cert.Website != nil {
return &ssl, r.Deploy(cert.ID, cert.WebsiteID)
return &ssl, r.Deploy(cert.ID, cert.WebsiteID, false)
}
return &ssl, nil
@@ -384,7 +386,7 @@ func (r *certRepo) RefreshRenewalInfo(id uint) (mholtacme.RenewalInfo, error) {
return renewInfo, nil
}
func (r *certRepo) Deploy(ID, WebsiteID uint) error {
func (r *certRepo) Deploy(ID, WebsiteID uint, enableHTTPS bool) error {
cert, err := r.Get(ID)
if err != nil {
return err
@@ -398,13 +400,62 @@ func (r *certRepo) Deploy(ID, WebsiteID uint) error {
if err = r.db.Where("id", WebsiteID).First(website).Error; err != nil {
return err
}
if err = io.Write(fmt.Sprintf("%s/sites/%s/config/fullchain.pem", app.Root, website.Name), cert.Cert, 0600); err != nil {
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
certPath := filepath.Join(configDir, "fullchain.pem")
keyPath := filepath.Join(configDir, "private.key")
if err = io.Write(certPath, cert.Cert, 0600); err != nil {
return err
}
if err = io.Write(fmt.Sprintf("%s/sites/%s/config/private.key", app.Root, website.Name), cert.Key, 0600); err != nil {
if err = io.Write(keyPath, cert.Key, 0600); err != nil {
return err
}
// 开启 HTTPS
if enableHTTPS && !website.SSL {
vhost, vhostErr := r.getVhost(website)
if vhostErr != nil {
return vhostErr
}
// 添加 443 监听
listens := vhost.Listen()
hasSSL := slices.ContainsFunc(listens, func(l webservertypes.Listen) bool {
return slices.Contains(l.Args, "ssl")
})
if !hasSSL {
webServer, _ := r.settingRepo.Get(biz.SettingKeyWebserver)
args := []string{"ssl"}
if webServer != "apache" {
args = append(args, "quic")
}
listens = append(listens, webservertypes.Listen{Address: "443", Args: args})
if err = vhost.SetListen(listens); err != nil {
return err
}
}
// 配置 SSL
defaultTLSVersions, _ := r.settingRepo.GetSlice(biz.SettingKeyWebsiteTLSVersions)
defaultCipherSuites, _ := r.settingRepo.Get(biz.SettingKeyWebsiteCipherSuites)
if err = vhost.SetSSLConfig(&webservertypes.SSLConfig{
Cert: certPath,
Key: keyPath,
Protocols: defaultTLSVersions,
Ciphers: defaultCipherSuites,
}); err != nil {
return err
}
if err = vhost.Save(); err != nil {
return err
}
website.SSL = true
if err = r.db.Save(website).Error; err != nil {
return err
}
}
webServer, _ := r.settingRepo.Get(biz.SettingKeyWebserver)
if webServer == "apache" {
if err = systemctl.Reload("apache"); err != nil {
@@ -421,6 +472,32 @@ func (r *certRepo) Deploy(ID, WebsiteID uint) error {
return nil
}
// getVhost 根据网站类型获取虚拟主机配置
func (r *certRepo) getVhost(website *biz.Website) (webservertypes.Vhost, error) {
webServer, err := r.settingRepo.Get(biz.SettingKeyWebserver)
if err != nil {
return nil, err
}
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
var vhost webservertypes.Vhost
switch website.Type {
case biz.WebsiteTypeProxy:
vhost, err = webserver.NewProxyVhost(webserver.Type(webServer), configDir)
case biz.WebsiteTypePHP:
vhost, err = webserver.NewPHPVhost(webserver.Type(webServer), configDir)
case biz.WebsiteTypeStatic:
vhost, err = webserver.NewStaticVhost(webserver.Type(webServer), configDir)
default:
return nil, errors.New(r.t.Get("unsupported website type: %s", website.Type))
}
if err != nil {
return nil, err
}
return vhost, nil
}
func (r *certRepo) runScript(cert *biz.Cert) error {
if cert.Script == "" {
return nil
+11 -4
View File
@@ -988,13 +988,18 @@ func (r *websiteRepo) UpdateCert(req *request.WebsiteUpdateCert) error {
return nil
}
func (r *websiteRepo) ObtainCert(ctx context.Context, id uint) error {
func (r *websiteRepo) ObtainCert(ctx context.Context, id uint, dnsID uint) error {
website, err := r.Get(id)
if err != nil {
return err
}
if slices.Contains(website.Domains, "*") {
return errors.New(r.t.Get("not support one-key obtain wildcard certificate, please use Cert menu to obtain it with DNS method"))
// 泛域名必须使用 DNS 验证
hasWildcard := slices.ContainsFunc(website.Domains, func(d string) bool {
return strings.Contains(d, "*")
})
if hasWildcard && dnsID == 0 {
return errors.New(r.t.Get("wildcard domains require DNS verification, please select a DNS provider"))
}
account, err := r.certAccount.GetDefault(cast.ToUint(ctx.Value("user_id")))
@@ -1010,6 +1015,7 @@ func (r *websiteRepo) ObtainCert(ctx context.Context, id uint) error {
Domains: website.Domains,
AutoRenewal: true,
AccountID: account.ID,
DNSID: dnsID,
WebsiteID: website.ID,
})
if err != nil {
@@ -1020,6 +1026,7 @@ func (r *websiteRepo) ObtainCert(ctx context.Context, id uint) error {
}
}
newCert.Domains = website.Domains
newCert.DNSID = dnsID
if err = r.db.Save(newCert).Error; err != nil {
return err
}
@@ -1029,7 +1036,7 @@ func (r *websiteRepo) ObtainCert(ctx context.Context, id uint) error {
return err
}
return r.cert.Deploy(newCert.ID, website.ID)
return r.cert.Deploy(newCert.ID, website.ID, false)
}
// customConfigStartNum 自定义配置起始序号
+3 -2
View File
@@ -42,6 +42,7 @@ func (r *CertUpdate) Rules(_ *http.Request) map[string]string {
}
type CertDeploy struct {
ID uint `form:"id" json:"id" validate:"required|exists:certs,id"`
WebsiteID uint `form:"website_id" json:"website_id" validate:"required|exists:websites,id"`
ID uint `form:"id" json:"id" validate:"required|exists:certs,id"`
WebsiteID uint `form:"website_id" json:"website_id" validate:"required|exists:websites,id"`
EnableHTTPS bool `form:"enable_https" json:"enable_https"`
}
+5
View File
@@ -127,3 +127,8 @@ type WebsiteUpdateCert struct {
Cert string `json:"cert" validate:"required"`
Key string `json:"key" validate:"required"`
}
type WebsiteObtainCert struct {
ID uint `json:"id" form:"id" uri:"id" validate:"required|exists:websites,id"`
DNSID uint `json:"dns_id" form:"dns_id"`
}
+1 -1
View File
@@ -272,7 +272,7 @@ func (s *CertService) Deploy(w http.ResponseWriter, r *http.Request) {
return
}
err = s.certRepo.Deploy(req.ID, req.WebsiteID)
err = s.certRepo.Deploy(req.ID, req.WebsiteID, req.EnableHTTPS)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
+2 -2
View File
@@ -239,13 +239,13 @@ func (s *WebsiteService) UpdateStatus(w http.ResponseWriter, r *http.Request) {
}
func (s *WebsiteService) ObtainCert(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ID](r)
req, err := Bind[request.WebsiteObtainCert](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.websiteRepo.ObtainCert(r.Context(), req.ID); err != nil {
if err = s.websiteRepo.ObtainCert(r.Context(), req.ID, req.DNSID); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
+2 -2
View File
@@ -47,6 +47,6 @@ export default {
// 续签
renew: (id: number): any => http.Post(`/cert/cert/${id}/renew`, { id }),
// 部署
deploy: (id: number, website_id: number): any =>
http.Post(`/cert/cert/${id}/deploy`, { id, website_id })
deploy: (id: number, website_id: number, enable_https: boolean = false): any =>
http.Post(`/cert/cert/${id}/deploy`, { id, website_id, enable_https })
}
+2 -1
View File
@@ -29,7 +29,8 @@ export default {
// 修改状态
status: (id: number, status: boolean): any => http.Post(`/website/${id}/status`, { status }),
// 签发证书
obtainCert: (id: number): any => http.Post(`/website/${id}/obtain_cert`),
obtainCert: (id: number, dns_id?: number): any =>
http.Post(`/website/${id}/obtain_cert`, dns_id ? { dns_id } : {}),
// 统计概览
statOverview: (start: string, end: string, sites?: string): any =>
http.Get('/website/stat/overview', { params: { start, end, sites } }),
+7 -5
View File
@@ -54,7 +54,8 @@ const showModel = ref<any>({
const deployModal = ref(false)
const deployModel = ref<any>({
id: null,
websites: []
websites: [],
enable_https: true
})
const obtain = ref(false)
const obtainCert = ref(0)
@@ -374,13 +375,14 @@ const handleAutoRenewalUpdate = (row: any) => {
const handleDeployCert = async () => {
const promises = deployModel.value.websites.map((website: any) =>
cert.deploy(deployModel.value.id, website)
cert.deploy(deployModel.value.id, website, deployModel.value.enable_https)
)
await Promise.all(promises)
deployModal.value = false
deployModel.value.id = null
deployModel.value.websites = []
deployModel.value.enable_https = true
window.$message.success($gettext('Deployment successful'))
}
@@ -540,9 +542,6 @@ onUnmounted(() => {
:segmented="false"
>
<n-flex vertical>
<n-alert type="info">
{{ $gettext('If website not enabled HTTPS, please enable it after deployment.') }}
</n-alert>
<n-form :model="deployModel">
<n-form-item path="website_id" :label="$gettext('Website')">
<n-select
@@ -553,6 +552,9 @@ onUnmounted(() => {
:options="websites"
/>
</n-form-item>
<n-form-item path="enable_https" :label="$gettext('Enable HTTPS')">
<n-switch v-model:value="deployModel.enable_https" />
</n-form-item>
</n-form>
<n-button type="info" block @click="handleDeployCert">{{ $gettext('Submit') }}</n-button>
</n-flex>
+71 -1
View File
@@ -180,12 +180,52 @@ const handleRewrite = (value: string) => {
}
const isObtainCert = ref(false)
const dnsModal = ref(false)
const dnsList = ref<any>([])
const selectedDnsId = ref<number | null>(null)
const handleObtainCert = () => {
// 检测泛域名
const hasWildcard = setting.value.domains?.some((domain: string) => domain.includes('*'))
if (hasWildcard) {
// 加载 DNS 列表并弹窗选择
useRequest(cert.dns(1, 10000)).onSuccess(({ data }) => {
dnsList.value = data.items.map((item: any) => ({
label: item.name,
value: item.id
}))
if (dnsList.value.length === 0) {
window.$message.error(
$gettext(
'Your website contains wildcard domains, which require DNS verification. Please add a DNS provider in Certificate Management first.'
)
)
return
}
selectedDnsId.value = null
dnsModal.value = true
})
return
}
doObtainCert()
}
const handleDnsObtainCert = () => {
if (!selectedDnsId.value) {
window.$message.error($gettext('Please select a DNS provider'))
return
}
dnsModal.value = false
doObtainCert(selectedDnsId.value)
}
const doObtainCert = (dnsId?: number) => {
isObtainCert.value = true
messageReactive = window.$message.loading($gettext('Please wait...'), {
duration: 0
})
useRequest(website.obtainCert(id.value))
useRequest(website.obtainCert(id.value, dnsId))
.onSuccess(() => {
fetchSetting()
window.$message.success($gettext('Issued successfully'))
@@ -2037,6 +2077,36 @@ const removeCustomConfig = (index: number) => {
</n-flex>
</template>
</n-modal>
<n-modal
v-model:show="dnsModal"
preset="card"
:title="$gettext('Select DNS Provider')"
style="width: 60vw"
:bordered="false"
:segmented="false"
>
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'Your website contains wildcard domains (e.g. *.example.com), which require DNS verification to issue certificates.'
)
}}
</n-alert>
<n-form>
<n-form-item :label="$gettext('DNS')">
<n-select
v-model:value="selectedDnsId"
:placeholder="$gettext('Select DNS for certificate issuance')"
:options="dnsList"
/>
</n-form-item>
</n-form>
<n-button type="info" block :disabled="!selectedDnsId" @click="handleDnsObtainCert">
{{ $gettext('Issue') }}
</n-button>
</n-flex>
</n-modal>
</template>
<style scoped>