Read configured_scheme/port and management_public_base; PUT domain with scheme/port only as backend expects.
466 lines
20 KiB
Vue
466 lines
20 KiB
Vue
<template>
|
||
<div class="settings-column settings-column-wide">
|
||
<!-- 公网入口契约 -->
|
||
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
||
<template #header>
|
||
<div class="card-head">
|
||
<b>{{ $t('tls.publicEntryTitle') }}</b>
|
||
<el-button link :loading="tlsLoading" @click="refreshAll">{{ $t('tls.refresh') }}</el-button>
|
||
</div>
|
||
</template>
|
||
<el-alert type="info" :closable="false" :title="$t('tls.publicEntryDesc')" style="margin-bottom:16px" />
|
||
|
||
<el-form label-position="top" @submit.prevent="savePublicEntry">
|
||
<el-form-item :label="$t('tls.scheme')">
|
||
<el-radio-group v-model="entry.scheme" class="scheme-group" @change="onSchemeChange">
|
||
<el-radio-button value="https">HTTPS</el-radio-button>
|
||
<el-radio-button value="http">HTTP</el-radio-button>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-alert
|
||
v-if="entry.scheme === 'http'"
|
||
type="warning"
|
||
:closable="false"
|
||
:title="$t('tls.httpRisk')"
|
||
style="margin-bottom:12px"
|
||
/>
|
||
<el-row :gutter="12">
|
||
<el-col :xs="24" :sm="16">
|
||
<el-form-item :label="$t('tls.host')" required>
|
||
<el-input v-model.trim="entry.host" placeholder="vpn.example.com" maxlength="253" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :xs="24" :sm="8">
|
||
<el-form-item :label="$t('tls.port')">
|
||
<el-input-number v-model="entry.port" :min="1" :max="65535" controls-position="right" style="width:100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item :label="$t('tls.publicBase')">
|
||
<el-input :model-value="publicBase" readonly>
|
||
<template #append>
|
||
<el-button @click="copyBase">{{ $t('common.copy') }}</el-button>
|
||
</template>
|
||
</el-input>
|
||
<div class="tls-hint">{{ $t('tls.publicBaseHint') }}</div>
|
||
</el-form-item>
|
||
<el-form-item :label="$t('tls.mode')">
|
||
<el-select v-model="entry.mode" style="width:100%">
|
||
<el-option value="autocert" :label="$t('tls.goMode')" />
|
||
<el-option value="nginx" :label="$t('tls.nginxMode')" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-button type="primary" :loading="tlsSaving" @click="savePublicEntry">{{ $t('tls.saveEntry') }}</el-button>
|
||
</el-form>
|
||
|
||
<el-alert v-if="tlsStatus?.restart_required" type="warning" :closable="false" :title="$t('tls.restartRequired')" style="margin-top:12px" />
|
||
<el-alert v-if="tlsStatus?.management_direct_public" type="warning" :closable="false" :title="$t('tls.directPortWarning')" style="margin-top:12px" />
|
||
</el-card>
|
||
|
||
<!-- 证书列表 -->
|
||
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
||
<template #header>
|
||
<div class="card-head">
|
||
<b>{{ $t('tls.certListTitle') }}</b>
|
||
<el-button type="primary" @click="openAddCert">{{ $t('tls.addCert') }}</el-button>
|
||
</div>
|
||
</template>
|
||
<el-table :data="certRows" size="default" v-loading="tlsLoading">
|
||
<el-table-column :label="$t('tls.host')" min-width="160">
|
||
<template #default="{ row }">{{ row.host || '—' }}</template>
|
||
</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>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="$t('tls.certificate')" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="row.valid ? 'success' : 'warning'">{{ row.valid ? $t('tls.valid') : row.pending ? $t('tls.pending') : $t('tls.notConfigured') }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="$t('tls.expires')" width="170">
|
||
<template #default="{ row }">{{ row.expires ? new Date(row.expires).toLocaleString() : '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="$t('tls.renewal')" min-width="140">
|
||
<template #default="{ row }">
|
||
<span>{{ row.renewalLabel }}</span>
|
||
<el-tag v-if="row.renewalFailed" type="danger" size="small" style="margin-left:6px">{{ $t('tls.renewalFailed') }}</el-tag>
|
||
</template>
|
||
</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>
|
||
</template>
|
||
</el-table-column>
|
||
<template #empty><el-empty :description="$t('tls.noCerts')" /></template>
|
||
</el-table>
|
||
</el-card>
|
||
|
||
<!-- Nginx 准备(折叠) -->
|
||
<el-collapse v-if="entry.mode === 'nginx'">
|
||
<el-collapse-item :title="$t('tls.setupTitle')" name="setup">
|
||
<el-steps :active="nginxSetup?.site_stage === 'https' ? 3 : nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready ? 2 : 1" finish-status="success" style="margin-bottom:16px">
|
||
<el-step :title="$t('tls.setupStepCheck')" />
|
||
<el-step :title="$t('tls.setupStepInstall')" />
|
||
<el-step :title="$t('tls.setupStepConfigure')" />
|
||
</el-steps>
|
||
<template v-if="nginxSetup">
|
||
<p class="tls-hint">{{ $t('tls.detectedSystem') }}:{{ nginxSetup.os }} {{ nginxSetup.version }} · {{ nginxSetup.package_manager || '—' }}</p>
|
||
<p class="tls-hint">Nginx:{{ nginxSetup.nginx_installed ? '✓' : '—' }} · Certbot:{{ nginxSetup.certbot_installed ? '✓' : '—' }} · HTTP-01 webroot:{{ nginxSetup.webroot_ready ? '✓' : '—' }}</p>
|
||
<p class="tls-hint">{{ $t('tls.siteStage') }}:{{ $t(`tls.stage_${nginxSetup.site_stage || 'none'}`) }}</p>
|
||
</template>
|
||
<el-alert type="info" :closable="false" :title="$t('tls.setupScope')" style="margin-bottom:12px" />
|
||
<el-button :loading="nginxSetupLoading" @click="loadNginxSetup">{{ $t('tls.refresh') }}</el-button>
|
||
<el-button type="primary" :loading="nginxInstalling" :disabled="!nginxSetup?.supported || !nginxSetup?.root || nginxSetup?.running || (nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready)" @click="initializeNginx">{{ $t('tls.setupButton') }}</el-button>
|
||
<pre v-if="nginxInstallLog" ref="nginxLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxInstallLog }}</pre>
|
||
<el-button style="margin-top:12px" :loading="nginxLoading" :disabled="!entry.host" @click="loadNginxPreview">{{ $t('tls.previewNginx') }}</el-button>
|
||
<template v-if="nginxPreview">
|
||
<p class="tls-hint">{{ $t('tls.httpSite') }}</p>
|
||
<pre class="nginx-preview">{{ nginxPreview.http_site }}</pre>
|
||
<p class="tls-hint">{{ $t('tls.httpsSite') }}</p>
|
||
<pre class="nginx-preview">{{ nginxPreview.https_site }}</pre>
|
||
</template>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
|
||
<!-- 添加证书弹窗 -->
|
||
<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'">
|
||
<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'">
|
||
<el-radio v-model="certForm.method" value="http01">{{ $t('tls.methodHttp01') }}</el-radio>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-if="certForm.method === 'upload'">
|
||
<el-form label-position="top" style="margin-top:16px">
|
||
<el-form-item :label="$t('tls.certPem')" required>
|
||
<el-input v-model="certForm.certPem" type="textarea" :rows="6" :placeholder="$t('tls.certPemPlaceholder')" />
|
||
<el-upload :show-file-list="false" accept=".crt,.pem,.cer" :before-upload="(f) => readPemFile(f, 'certPem')">
|
||
<el-button class="upload-btn" style="width:100%;margin-top:8px">{{ $t('tls.uploadCertFile') }}</el-button>
|
||
</el-upload>
|
||
</el-form-item>
|
||
<el-form-item :label="$t('tls.keyPem')" required>
|
||
<el-input v-model="certForm.keyPem" type="textarea" :rows="6" :placeholder="$t('tls.keyPemPlaceholder')" />
|
||
<el-upload :show-file-list="false" accept=".key,.pem" :before-upload="(f) => readPemFile(f, 'keyPem')">
|
||
<el-button class="upload-btn" style="width:100%;margin-top:8px">{{ $t('tls.uploadKeyFile') }}</el-button>
|
||
</el-upload>
|
||
</el-form-item>
|
||
</el-form>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<el-form label-position="top" style="margin-top:16px">
|
||
<el-form-item :label="$t('tls.host')" required>
|
||
<el-input v-model.trim="certForm.host" :placeholder="$t('tls.multiDomainHint')" />
|
||
</el-form-item>
|
||
<el-form-item :label="$t('tls.applyEmail')">
|
||
<el-input v-model.trim="certForm.email" placeholder="admin@example.com" />
|
||
</el-form-item>
|
||
<el-alert type="info" :closable="false" :title="$t('tls.http01Hint')" />
|
||
</el-form>
|
||
</template>
|
||
|
||
<template #footer>
|
||
<el-button @click="certDialog = false">{{ $t('common.cancel') }}</el-button>
|
||
<el-button type="primary" :loading="certSubmitting" @click="submitCert">{{ $t('common.submit') }}</el-button>
|
||
</template>
|
||
<pre v-if="nginxActivateLog" ref="nginxActivateLogView" class="nginx-preview" role="log" aria-live="polite" style="margin-top:12px">{{ nginxActivateLog }}</pre>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useI18n } from 'vue-i18n'
|
||
import api, { auth } from '../../../api'
|
||
import { buildPublicBase, defaultPort } from '../../../utils/publicBase'
|
||
|
||
const { t } = useI18n()
|
||
const tlsStatus = ref(null)
|
||
const tlsLoading = ref(false)
|
||
const tlsSaving = ref(false)
|
||
const nginxLoading = ref(false)
|
||
const nginxPreview = ref(null)
|
||
const nginxSetup = ref(null)
|
||
const nginxSetupLoading = ref(false)
|
||
const nginxInstalling = ref(false)
|
||
const nginxInstallLog = ref('')
|
||
const nginxLogView = ref(null)
|
||
const nginxActivateLog = ref('')
|
||
const nginxActivateLogView = ref(null)
|
||
const certDialog = ref(false)
|
||
const certSubmitting = ref(false)
|
||
|
||
const entry = reactive({
|
||
scheme: 'https',
|
||
host: '',
|
||
port: 443,
|
||
mode: 'autocert',
|
||
})
|
||
|
||
const certForm = reactive({
|
||
method: 'upload',
|
||
certPem: '',
|
||
keyPem: '',
|
||
host: '',
|
||
email: '',
|
||
})
|
||
|
||
const publicBase = computed(() => {
|
||
const local = buildPublicBase(entry)
|
||
const remote = tlsStatus.value?.configured_public_base || tlsStatus.value?.public_base || ''
|
||
// Prefer live form preview while editing; fall back to server after load.
|
||
return local || remote
|
||
})
|
||
|
||
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'))
|
||
return [{
|
||
host,
|
||
source,
|
||
valid: !!s.certificate_valid,
|
||
pending: !!s.enabled && !s.certificate_valid,
|
||
expires: s.not_after || null,
|
||
renewalFailed: !!s.renewal_failed,
|
||
renewalLabel: s.enabled ? t('tls.automatic') : s.mode === 'nginx' ? t('tls.externalRenewal') : '—',
|
||
}]
|
||
})
|
||
|
||
function onSchemeChange() {
|
||
entry.port = defaultPort(entry.scheme)
|
||
}
|
||
|
||
function resetCertForm() {
|
||
certForm.method = 'upload'
|
||
certForm.certPem = ''
|
||
certForm.keyPem = ''
|
||
certForm.host = entry.host
|
||
certForm.email = ''
|
||
nginxActivateLog.value = ''
|
||
}
|
||
|
||
function openAddCert() {
|
||
resetCertForm()
|
||
certDialog.value = true
|
||
}
|
||
|
||
function readPemFile(file, field) {
|
||
const reader = new FileReader()
|
||
reader.onload = () => { certForm[field] = String(reader.result || '') }
|
||
reader.readAsText(file)
|
||
return false
|
||
}
|
||
|
||
async function copyBase() {
|
||
if (!publicBase.value) return
|
||
try {
|
||
await navigator.clipboard.writeText(publicBase.value)
|
||
ElMessage.success(t('common.copied'))
|
||
} catch { ElMessage.error(t('common.copyFailed')) }
|
||
}
|
||
|
||
async function refreshAll() {
|
||
await Promise.all([loadTLS(), loadNginxSetup()])
|
||
}
|
||
|
||
async function loadNginxSetup() {
|
||
nginxSetupLoading.value = true
|
||
try {
|
||
const { data } = await api.get('/system/tls/nginx-setup')
|
||
nginxSetup.value = data
|
||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
|
||
finally { nginxSetupLoading.value = false }
|
||
}
|
||
|
||
async function initializeNginx() {
|
||
nginxInstalling.value = true
|
||
nginxInstallLog.value = ''
|
||
let completed = false
|
||
try {
|
||
const response = await fetch('/api/system/tls/nginx-setup', {
|
||
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` },
|
||
})
|
||
if (!response.ok) {
|
||
const body = await response.json().catch(() => ({}))
|
||
throw new Error(body.error || `${response.status} ${response.statusText}`)
|
||
}
|
||
const reader = response.body.getReader()
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
const show = (line) => {
|
||
if (!line) return
|
||
const event = JSON.parse(line)
|
||
nginxInstallLog.value = (nginxInstallLog.value + (event.message || '')).slice(-100000)
|
||
if (event.type === 'done') completed = true
|
||
if (event.type === 'error') throw new Error(event.message)
|
||
nextTick(() => { if (nginxLogView.value) nginxLogView.value.scrollTop = nginxLogView.value.scrollHeight })
|
||
}
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const lines = buffer.split('\n')
|
||
buffer = lines.pop()
|
||
for (const line of lines) show(line)
|
||
}
|
||
if (buffer) show(buffer)
|
||
if (!completed) throw new Error(t('tls.setupInterrupted'))
|
||
ElMessage.success(t('tls.setupComplete'))
|
||
} catch (e) {
|
||
nginxInstallLog.value += `\n${e.message}\n`
|
||
ElMessage.error(t('tls.setupFailed'))
|
||
} finally {
|
||
nginxInstalling.value = false
|
||
await loadNginxSetup()
|
||
}
|
||
}
|
||
|
||
async function loadTLS() {
|
||
tlsLoading.value = true
|
||
try {
|
||
const { data } = await api.get('/system/tls')
|
||
tlsStatus.value = data
|
||
entry.host = data.configured_domain || data.domain || data.host || ''
|
||
const scheme = data.configured_scheme || data.scheme || 'https'
|
||
entry.scheme = scheme === 'http' ? 'http' : 'https'
|
||
const port = Number(data.configured_port || data.port)
|
||
entry.port = port > 0 ? port : defaultPort(entry.scheme)
|
||
entry.mode = data.configured_mode || data.mode || 'autocert'
|
||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
|
||
finally { tlsLoading.value = false }
|
||
}
|
||
|
||
async function savePublicEntry() {
|
||
if (!entry.host) return ElMessage.warning(t('tls.hostRequired'))
|
||
tlsSaving.value = true
|
||
try {
|
||
const { data: domainRes } = await api.put('/system/tls/domain', {
|
||
domain: entry.host,
|
||
scheme: entry.scheme,
|
||
port: entry.port,
|
||
})
|
||
tlsStatus.value = domainRes.status
|
||
const { data: modeRes } = await api.put('/system/tls/mode', { mode: entry.mode })
|
||
tlsStatus.value = modeRes.status || domainRes.status
|
||
nginxPreview.value = null
|
||
ElMessage.success((domainRes.restart_required || modeRes.restart_required) ? t('tls.restartRequired') : t('tls.saved'))
|
||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
|
||
finally { tlsSaving.value = false }
|
||
}
|
||
|
||
async function loadNginxPreview() {
|
||
nginxLoading.value = true
|
||
try {
|
||
const { data } = await api.get('/system/tls/nginx-preview')
|
||
nginxPreview.value = data
|
||
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
|
||
finally { nginxLoading.value = false }
|
||
}
|
||
|
||
async function streamActivate() {
|
||
nginxActivateLog.value = ''
|
||
let completed = false
|
||
const response = await fetch('/api/system/tls/nginx-activate', {
|
||
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` },
|
||
})
|
||
if (!response.ok) {
|
||
const body = await response.json().catch(() => ({}))
|
||
throw new Error(body.error || `${response.status} ${response.statusText}`)
|
||
}
|
||
const reader = response.body.getReader()
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
const show = (line) => {
|
||
if (!line) return
|
||
const event = JSON.parse(line)
|
||
nginxActivateLog.value = (nginxActivateLog.value + (event.message || '')).slice(-100000)
|
||
if (event.type === 'done') completed = true
|
||
if (event.type === 'error') throw new Error(event.message)
|
||
nextTick(() => { if (nginxActivateLogView.value) nginxActivateLogView.value.scrollTop = nginxActivateLogView.value.scrollHeight })
|
||
}
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const lines = buffer.split('\n')
|
||
buffer = lines.pop()
|
||
for (const line of lines) show(line)
|
||
}
|
||
if (buffer) show(buffer)
|
||
if (!completed) throw new Error(t('tls.setupInterrupted'))
|
||
}
|
||
|
||
async function submitCert() {
|
||
certSubmitting.value = true
|
||
try {
|
||
if (certForm.method === 'upload') {
|
||
if (!certForm.certPem.trim() || !certForm.keyPem.trim()) {
|
||
ElMessage.warning(t('tls.certKeyRequired'))
|
||
return
|
||
}
|
||
await api.post('/system/tls/certificate', {
|
||
certificate_pem: certForm.certPem,
|
||
private_key_pem: certForm.keyPem,
|
||
host: entry.host || certForm.host,
|
||
})
|
||
ElMessage.success(t('tls.certUploaded'))
|
||
certDialog.value = false
|
||
await loadTLS()
|
||
return
|
||
}
|
||
// HTTP-01: ensure domain saved, nginx mode, then activate
|
||
const host = (certForm.host || entry.host || '').trim()
|
||
if (!host) return ElMessage.warning(t('tls.hostRequired'))
|
||
entry.host = host.split(/[\s,]+/)[0]
|
||
await api.put('/system/tls/domain', {
|
||
domain: entry.host, scheme: entry.scheme, port: entry.port,
|
||
})
|
||
if (entry.mode !== 'nginx') {
|
||
await api.put('/system/tls/mode', { mode: 'nginx' })
|
||
entry.mode = 'nginx'
|
||
}
|
||
await streamActivate()
|
||
ElMessage.success(t('tls.siteReady'))
|
||
certDialog.value = false
|
||
await refreshAll()
|
||
} catch (e) {
|
||
const msg = e.response?.data?.error || e.message || t('tls.siteFailed')
|
||
if (certForm.method === 'upload' && (e.response?.status === 404 || e.response?.status === 405)) {
|
||
ElMessage.error(t('tls.uploadApiPending'))
|
||
} else {
|
||
ElMessage.error(msg)
|
||
}
|
||
} finally { certSubmitting.value = false }
|
||
}
|
||
|
||
onMounted(() => { if (!auth.mustChange) refreshAll() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.settings-column { max-width: 920px; }
|
||
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
|
||
.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; }
|
||
.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-card {
|
||
border: 1px solid var(--el-border-color); border-radius: 10px; padding: 14px 16px; cursor: pointer;
|
||
transition: border-color .15s, box-shadow .15s;
|
||
}
|
||
.method-card.active { border-color: var(--el-color-primary); box-shadow: 0 0 0 1px var(--el-color-primary) inset; }
|
||
.upload-btn { color: var(--el-color-primary); border-style: dashed; }
|
||
</style>
|