Add guarded IP certificate flow and renewal history

This commit is contained in:
Codex committed 2026-10-26 15:32:27 +08:00
1 parent dd94a4cc84
commit d3699abb18
14 files changed
+343 -56

No files matched your search

+3
View File
@@ -966,6 +966,9 @@
"saveEntry": "Save public entry",
"hostRequired": "Enter a domain or IPv4 address",
"certListTitle": "Certificates",
"httpNoCertificate": "This HTTP gateway has no TLS certificate or renewal. Switch to HTTPS to request one.",
"ipCertificateRequirement": "IP certificates last about six days. Automated issuance needs Certbot 5.4+, public port 80, and scheduled renewal. Unsupported server versions cannot issue one.",
"renewalHistory": "Recent successful renewals",
"addCert": "Add certificate",
"replaceCert": "Replace",
"certSource": "Source",
+3
View File
@@ -966,6 +966,9 @@
"saveEntry": "保存公网入口",
"hostRequired": "请填写域名或 IPv4 地址",
"certListTitle": "证书",
"httpNoCertificate": "当前是 HTTP 入口,没有 TLS 证书,也不会自动续签。切换 HTTPS 后才能申请证书。",
"ipCertificateRequirement": "IP 证书有效期约 6 天;自动申请需要 Certbot 5.4+、公网 80 端口和定时续签。当前服务器版本不足时会阻止签发。",
"renewalHistory": "最近成功续签",
"addCert": "添加证书",
"replaceCert": "更换",
"certSource": "来源",
@@ -59,7 +59,7 @@
<template #header>
<div class="card-head">
<b>{{ $t('tls.certListTitle') }}</b>
<el-button type="primary" :disabled="legacyMode" @click="openAddCert">{{ $t('tls.addCert') }}</el-button>
<el-button type="primary" :disabled="legacyMode || entry.scheme === 'http'" @click="openAddCert">{{ $t('tls.addCert') }}</el-button>
</div>
</template>
<el-table :data="certRows" size="default" v-loading="tlsLoading">
@@ -68,7 +68,7 @@
</el-table-column>
<el-table-column :label="$t('tls.certSource')" width="120">
<template #default="{ row }">
<el-tag size="small" :type="row.source === 'upload' ? 'info' : 'success'">{{ $t(row.source === 'upload' ? 'tls.sourceUpload' : 'tls.sourceHttp01') }}</el-tag>
<el-tag size="small" :type="row.source === 'upload' ? 'info' : 'success'">{{ row.source === 'upload' ? $t('tls.sourceUpload') : row.source === 'http01' ? $t('tls.sourceHttp01') : '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('tls.certificate')" width="110">
@@ -87,11 +87,17 @@
</el-table-column>
<el-table-column :label="$t('common.operation')" width="120" fixed="right">
<template #default>
<el-button link type="primary" @click="openAddCert">{{ $t('tls.replaceCert') }}</el-button>
<el-button link type="primary" :disabled="legacyMode" @click="openAddCert">{{ $t('tls.replaceCert') }}</el-button>
</template>
</el-table-column>
<template #empty><el-empty :description="$t('tls.noCerts')" /></template>
<template #empty><el-empty :description="$t(entry.scheme === 'http' ? 'tls.httpNoCertificate' : 'tls.noCerts')" /></template>
</el-table>
<el-alert v-if="entry.scheme === 'http'" type="info" :closable="false" :title="$t('tls.httpNoCertificate')" style="margin-top:12px" />
<el-alert v-if="entry.scheme === 'https' && isIPv4(entry.host)" type="warning" :closable="false" :title="$t('tls.ipCertificateRequirement')" style="margin-top:12px" />
<div v-if="recentRenewals.length && entry.scheme === 'https'" class="tls-renewals">
<b>{{ $t('tls.renewalHistory') }}</b>
<p v-for="event in recentRenewals" :key="`${event.time}-${event.host}`" class="tls-hint">{{ new Date(event.time).toLocaleString() }} · {{ event.host }}</p>
</div>
</el-card>
<!-- Nginx 准备(折叠) -->
@@ -124,7 +130,7 @@
<!-- 添加证书弹窗 -->
<el-dialog v-model="certDialog" :title="$t('tls.addCert')" width="640px" destroy-on-close @closed="resetCertForm">
<div class="method-cards">
<div class="method-card" :class="{ active: certForm.method === 'upload' }" @click="certForm.method = 'upload'">
<div v-if="!isIPv4(entry.host)" class="method-card" :class="{ active: certForm.method === 'upload' }" @click="certForm.method = 'upload'">
<el-radio v-model="certForm.method" value="upload">{{ $t('tls.methodUpload') }}</el-radio>
</div>
<div class="method-card" :class="{ active: certForm.method === 'http01' }" @click="certForm.method = 'http01'">
@@ -231,6 +237,7 @@ const entry = reactive({
host: '',
port: 80,
})
const recentRenewals = computed(() => (tlsStatus.value?.renewal_events || []).filter(e => e.host === entry.host).slice(0, 5))
const certForm = reactive({
method: 'upload',
@@ -246,20 +253,25 @@ const publicBase = computed(() => {
return buildPublicBase(entry)
})
function isIPv4(host) {
const parts = String(host || '').split('.')
return parts.length === 4 && parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)
}
const certRows = computed(() => {
const s = tlsStatus.value
if (!s) return []
const host = s.configured_domain || s.domain || entry.host
if (!host && !s.certificate_valid && !s.enabled && !s.certificate_cached) return []
const source = s.cert_source || (s.mode === 'nginx' || s.enabled ? 'http01' : (s.certificate_cached ? 'upload' : 'http01'))
if (!s || entry.scheme !== 'https' || s.configured_scheme !== 'https') return []
const host = s.configured_domain || s.domain
if (!host || (!s.certificate_cached && !s.certificate_valid) || s.configured_public_base !== s.public_base) return []
const source = s.cert_source || (s.enabled ? 'http01' : '')
return [{
host,
source,
valid: !!s.certificate_valid,
pending: !!s.enabled && !s.certificate_valid,
expires: s.not_after || null,
expires: s.not_after && !s.not_after.startsWith('0001-') ? s.not_after : null,
renewalFailed: !!s.renewal_failed,
renewalLabel: s.enabled ? t('tls.automatic') : s.mode === 'nginx' ? t('tls.externalRenewal') : '—',
renewalLabel: source === 'http01' ? (s.enabled ? t('tls.automatic') : t('tls.externalRenewal')) : '—',
}]
})
@@ -268,7 +280,7 @@ function onSchemeChange() {
}
function resetCertForm() {
certForm.method = 'upload'
certForm.method = isIPv4(entry.host) ? 'http01' : 'upload'
certForm.certPem = ''
certForm.keyPem = ''
certForm.certFileName = ''
@@ -481,13 +493,14 @@ onMounted(() => { if (!auth.mustChange) refreshAll() })
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; margin-top: 6px; }
.tls-renewals { margin-top: 12px; }
.derived-base :deep(.el-input__wrapper) { background: var(--el-fill-color-light); }
.derived-base :deep(.el-input__inner) { color: var(--el-text-color-secondary); cursor: default; }
.nginx-preview { max-height: 280px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; margin-top: 8px; }
.scheme-group { width: 100%; }
.scheme-group :deep(.el-radio-button) { flex: 1; }
.scheme-group :deep(.el-radio-button__inner) { width: 100%; }
.method-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.method-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
.method-card {
border: 1px solid var(--el-border-color); border-radius: 10px; padding: 14px 16px; cursor: pointer;
transition: border-color .15s, box-shadow .15s;