feat(cloudflare): 新增 Cloudflare 隧道独立模块并发布 v1.6.0
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-28 16:36:05 +08:00
parent 66081e93d5
commit a8a33e87d2
30 changed files with 2133 additions and 56 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "fonu-web",
"version": "1.5.4",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fonu-web",
"version": "1.5.4",
"version": "1.6.0",
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "fonu-web",
"private": true,
"version": "1.5.4",
"version": "1.6.0",
"packageManager": "npm@10.9.4",
"type": "module",
"scripts": {
+33
View File
@@ -37,6 +37,10 @@ import type {
SystemLogEntry,
NotifyTestPayload,
UpdateStatus,
CfMirror,
CfRuntimeStatus,
CfTunnel,
CfTunnelPayload,
} from './types'
export function asList<T>(value: T[] | null | undefined): T[] {
@@ -474,4 +478,33 @@ export const api = {
}
return (await response.json()) as { message: string }
},
listCfTunnels: () => request<CfTunnel[]>('/api/cloudflare/tunnels'),
getCfTunnel: (id: number) => request<CfTunnel>(`/api/cloudflare/tunnels/${id}`),
createCfTunnel: (payload: CfTunnelPayload) =>
request<CfTunnel>('/api/cloudflare/tunnels', { method: 'POST', body: JSON.stringify(payload) }),
updateCfTunnel: (id: number, payload: CfTunnelPayload) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteCfTunnel: (id: number) =>
request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}`, { method: 'DELETE' }),
startCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/start`, { method: 'POST' }),
stopCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }),
restartCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }),
getCfTunnelStatus: (id: number) =>
request<CfRuntimeStatus>(`/api/cloudflare/tunnels/${id}/status`),
getCfTunnelLogs: (id: number, lines = 200) =>
request<{ output: string }>(`/api/cloudflare/tunnels/${id}/logs?lines=${lines}`),
getCfTunnelConfig: (id: number) =>
request<{ content: string }>(`/api/cloudflare/tunnels/${id}/config`),
getCfBinary: () => request<{ version: string; mirrors: CfMirror[] }>('/api/cloudflare/binary'),
downloadCfBinary: (mirror: string) =>
request<{ ok: boolean; version: string }>('/api/cloudflare/binary/download', {
method: 'POST',
body: JSON.stringify({ mirror }),
}),
getCfIngressCandidates: () =>
request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'),
}
+58
View File
@@ -1038,3 +1038,61 @@ export interface StatusPagePayload {
unknown: number
routes: StatusPageRoute[]
}
// Cloudflare Tunnel 独立模块
export interface CfNetworkSettings {
transport_protocol: string
edge_ip_version: string
ha_connections: number
proxy_mode: string
origin_ca_pool?: string
no_tls_verify?: boolean
http_host_header?: string
}
export interface CfTunnel {
id: number
name: string
mode: string
tunnel_id: string
account_tag: string
config_path: string
creds_path: string
log_path: string
network: CfNetworkSettings
status: string
last_error?: string
metrics_port?: number
auto_start: boolean
created_at: string
updated_at: string
}
export interface CfRuntimeStatus {
running: boolean
status: string
last_error?: string
metrics_port?: number
connections: number
latency_ms: number
bandwidth_in: number
bandwidth_out: number
transport: string
bandwidth_notice?: string
}
export interface CfMirror {
id: string
name: string
base: string
}
export interface CfTunnelPayload {
name: string
mode: string
token?: string
hostnames: string[]
origin_service?: string
network: CfNetworkSettings
auto_start: boolean
}
+1
View File
@@ -201,6 +201,7 @@ const menuDefs = [
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
{ label: '系统设置', key: 'settings', icon: CogOutline },
+7
View File
@@ -9,6 +9,7 @@ import CertificatesView from '../views/CertificatesView.vue'
import LogsView from '../views/LogsView.vue'
import SettingsView from '../views/SettingsView.vue'
import FrpView from '../views/FrpView.vue'
import CloudflareView from '../views/CloudflareView.vue'
import AgentView from '../views/AgentView.vue'
import ProxyRemoteView from '../views/ProxyRemoteView.vue'
import StatusPageView from '../views/StatusPageView.vue'
@@ -63,6 +64,12 @@ const router = createRouter({
component: AgentView,
meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 fonu-agent 与 frps' },
},
{
path: 'cloudflare',
name: 'cloudflare',
component: CloudflareView,
meta: { title: 'Cloudflare 隧道', description: '通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘' },
},
{
path: 'public-proxy',
name: 'public-proxy',
+379
View File
@@ -0,0 +1,379 @@
<template>
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
<template #actions>
<n-button :loading="binaryLoading" @click="openBinary">cloudflared 二进制</n-button>
<n-button type="primary" @click="openCreate">新建隧道</n-button>
</template>
</PageHeader>
<n-alert v-if="binaryVersion" type="info" :bordered="false" class="cf-alert">
已安装:<span class="mono">{{ binaryVersion }}</span>
</n-alert>
<n-alert v-else type="warning" :bordered="false" class="cf-alert">
尚未安装 cloudflared。请先点右上角「cloudflared 二进制」下载,再创建隧道。
</n-alert>
<FonuCard title="隧道列表" subtitle="token 模式默认使用 Fonu 生成的本地 ingress;云端规则模式请在 Cloudflare 面板管理">
<div v-if="!tunnels.length" class="cf-empty">还没有隧道,点「新建隧道」开始。</div>
<div v-for="item in tunnels" :key="item.id" class="cf-row">
<div class="cf-row__main">
<strong>{{ item.name }}</strong>
<StatusBadge :kind="statusKind(item.status)" :text="statusText(item.status)" />
<span class="cf-meta mono">{{ modeText(item.mode) }}</span>
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
</div>
<div class="cf-row__actions">
<n-button size="small" @click="openDetail(item)">详情</n-button>
<n-button size="small" @click="openEdit(item)">编辑</n-button>
<n-button v-if="item.status !== 'connected' && item.status !== 'starting'" size="small" type="success" @click="startTunnel(item)">启动</n-button>
<n-button v-else size="small" type="warning" @click="stopTunnel(item)">停止</n-button>
<n-button size="small" @click="restartTunnel(item)">重启</n-button>
<n-popconfirm @positive-click="removeTunnel(item)">
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template>
将停止该隧道并删除本地配置、凭据与日志。继续?
</n-popconfirm>
</div>
</div>
</FonuCard>
<n-modal v-model:show="editorVisible" preset="card" class="cf-modal" :title="editing ? '编辑隧道' : '新建隧道'">
<n-form label-placement="top">
<n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item>
<n-form-item label="模式">
<n-select v-model:value="form.mode" :options="modeOptions" />
</n-form-item>
<n-form-item :label="editing ? 'Token(留空保留原值)' : 'Token'">
<n-input v-model:value="form.token" type="password" show-password-on="click" placeholder="Cloudflare 面板生成的隧道 token" />
</n-form-item>
<n-form-item label="暴露域名">
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" />
</n-form-item>
<n-form-item label="回源 service">
<n-input v-model:value="form.origin_service" placeholder="http://127.0.0.1:80" />
</n-form-item>
<n-form-item label="传输协议">
<n-select v-model:value="form.network.transport_protocol" :options="protocolOptions" />
</n-form-item>
<n-form-item label="边缘地址族">
<n-select v-model:value="form.network.edge_ip_version" :options="edgeOptions" />
</n-form-item>
<n-form-item label="HA 连接数"><n-input-number v-model:value="form.network.ha_connections" :min="1" :max="4" /></n-form-item>
<n-form-item label="代理模式">
<n-select v-model:value="form.network.proxy_mode" :options="proxyOptions" />
</n-form-item>
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
</n-form>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="editorVisible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="saveTunnel">保存</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="detailVisible" preset="card" class="cf-modal" :title="detailTunnel?.name || '隧道详情'">
<div v-if="detailTunnel" class="cf-detail">
<div class="cf-detail__row">
<StatusBadge :kind="statusKind(detailStatus?.status || detailTunnel.status)" :text="statusText(detailStatus?.status || detailTunnel.status)" />
<span class="cf-meta mono">连接数 {{ detailStatus?.connections ?? 0 }}</span>
<span class="cf-meta mono">延迟 {{ (detailStatus?.latency_ms ?? 0).toFixed(0) }} ms</span>
<span class="cf-meta mono">{{ detailStatus?.transport || '—' }}</span>
</div>
<p v-if="detailStatus?.bandwidth_notice" class="cf-hint">{{ detailStatus.bandwidth_notice }}</p>
<h4>config.yml</h4>
<pre class="cf-pre">{{ detailConfig || '暂无配置' }}</pre>
<h4>日志</h4>
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="refreshDetail">刷新</n-button>
<n-button type="primary" @click="detailVisible = false">关闭</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="binaryVisible" preset="card" class="cf-modal" title="cloudflared 二进制">
<n-form label-placement="top">
<n-form-item label="当前版本"><span class="mono">{{ binaryVersion || '未安装' }}</span></n-form-item>
<n-form-item label="下载镜像">
<n-select v-model:value="selectedMirror" :options="mirrorOptions" />
</n-form-item>
</n-form>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="binaryVisible = false">取消</n-button>
<n-button type="primary" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { NAlert, NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, useMessage } from 'naive-ui'
import { api, asList } from '../api/client'
import type { CfMirror, CfRuntimeStatus, CfTunnel, CfTunnelPayload } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { StatusKind } from '../utils/status'
const message = useMessage()
const tunnels = ref<CfTunnel[]>([])
const binaryVersion = ref('')
const mirrors = ref<CfMirror[]>([])
const candidates = ref<string[]>([])
const loading = ref(false)
const saving = ref(false)
const binaryLoading = ref(false)
const selectedMirror = ref('official')
const editorVisible = ref(false)
const binaryVisible = ref(false)
const detailVisible = ref(false)
const editing = ref<CfTunnel | null>(null)
const detailTunnel = ref<CfTunnel | null>(null)
const detailStatus = ref<CfRuntimeStatus | null>(null)
const detailConfig = ref('')
const detailLogs = ref('')
const form = ref<CfTunnelPayload>(emptyForm())
const modeOptions = [
{ label: 'Token + 本地规则(Fonu 生成 ingress)', value: 'token-local' },
{ label: 'Token + 云端规则(Cloudflare 面板管理)', value: 'token-remote' },
]
const protocolOptions = [
{ label: 'HTTP/2(国内推荐)', value: 'http2' },
{ label: 'QUIC', value: 'quic' },
{ label: '自动', value: 'auto' },
]
const edgeOptions = [
{ label: 'IPv4(推荐)', value: '4' },
{ label: 'IPv6', value: '6' },
{ label: '自动', value: 'auto' },
]
const proxyOptions = [
{ label: '跟随系统环境变量', value: 'system' },
{ label: '禁用代理', value: 'disabled' },
]
const hostnameOptions = computed(() => candidates.value.map((host) => ({ label: host, value: host })))
const mirrorOptions = computed(() => mirrors.value.map((item) => ({ label: item.name, value: item.id })))
function emptyForm(): CfTunnelPayload {
return {
name: '',
mode: 'token-local',
token: '',
hostnames: [],
origin_service: 'http://127.0.0.1:80',
network: { transport_protocol: 'http2', edge_ip_version: '4', ha_connections: 2, proxy_mode: 'system' },
auto_start: false,
}
}
async function load() {
loading.value = true
try {
tunnels.value = asList(await api.listCfTunnels())
} catch (error) {
message.error(error instanceof Error ? error.message : '读取隧道列表失败')
} finally {
loading.value = false
}
}
async function loadBinary() {
try {
const result = await api.getCfBinary()
binaryVersion.value = result.version || ''
mirrors.value = asList(result.mirrors)
} catch {
// 二进制状态不影响列表
}
}
async function loadCandidates() {
try {
candidates.value = (await api.getCfIngressCandidates()).hostnames || []
} catch {
candidates.value = []
}
}
function openCreate() {
editing.value = null
form.value = emptyForm()
editorVisible.value = true
}
function openEdit(item: CfTunnel) {
editing.value = item
form.value = {
name: item.name,
mode: item.mode,
token: '',
hostnames: [],
origin_service: 'http://127.0.0.1:80',
network: { ...item.network },
auto_start: item.auto_start,
}
editorVisible.value = true
}
async function saveTunnel() {
saving.value = true
try {
if (editing.value) {
await api.updateCfTunnel(editing.value.id, form.value)
} else {
await api.createCfTunnel(form.value)
}
editorVisible.value = false
message.success('隧道已保存')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存隧道失败')
} finally {
saving.value = false
}
}
async function startTunnel(item: CfTunnel) {
await runAction('启动', () => api.startCfTunnel(item.id))
}
async function stopTunnel(item: CfTunnel) {
await runAction('停止', () => api.stopCfTunnel(item.id))
}
async function restartTunnel(item: CfTunnel) {
await runAction('重启', () => api.restartCfTunnel(item.id))
}
async function removeTunnel(item: CfTunnel) {
try {
await api.deleteCfTunnel(item.id)
message.success('隧道已删除')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除隧道失败')
}
}
async function runAction(label: string, fn: () => Promise<unknown>) {
try {
await fn()
message.success(`${label}指令已下发`)
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : `${label}失败`)
}
}
async function openDetail(item: CfTunnel) {
detailTunnel.value = item
detailVisible.value = true
await refreshDetail()
}
async function refreshDetail() {
const item = detailTunnel.value
if (!item) return
try {
const [status, config, logs] = await Promise.all([
api.getCfTunnelStatus(item.id),
api.getCfTunnelConfig(item.id),
api.getCfTunnelLogs(item.id, 200),
])
detailStatus.value = status
detailConfig.value = config.content || ''
detailLogs.value = logs.output || ''
} catch (error) {
message.error(error instanceof Error ? error.message : '读取隧道详情失败')
}
}
function openBinary() {
binaryVisible.value = true
}
async function downloadBinary() {
binaryLoading.value = true
try {
const result = await api.downloadCfBinary(selectedMirror.value)
binaryVersion.value = result.version || ''
message.success('cloudflared 已下载并安装')
} catch (error) {
message.error(error instanceof Error ? error.message : '下载 cloudflared 失败')
} finally {
binaryLoading.value = false
}
}
function modeText(mode: string) {
if (mode === 'token-remote') return '云端规则'
if (mode === 'token-local') return '本地规则'
return mode
}
function statusText(status: string) {
return ({
connected: '已连接',
starting: '启动中',
reconnecting: '重连中',
failed: '失败',
stopped: '已停止',
} as Record<string, string>)[status] || status || '未知'
}
function statusKind(status: string): StatusKind {
if (status === 'connected') return 'success'
if (status === 'starting' || status === 'reconnecting') return 'warning'
if (status === 'failed') return 'error'
return 'unknown'
}
onMounted(() => {
void load()
void loadBinary()
void loadCandidates()
})
useVisibilityPolling(() => load(), 5000)
</script>
<style scoped>
.cf-alert { margin-bottom: var(--fonu-space-4); }
.cf-empty { padding: 24px 0; color: var(--fonu-text-muted); }
.cf-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
padding: 12px 0;
border-bottom: 1px solid var(--fonu-border);
}
.cf-row:last-child { border-bottom: none; }
.cf-row__main { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.cf-row__actions { display: flex; gap: 6px; flex-wrap: wrap; }
.cf-meta { font-size: 12px; color: var(--fonu-text-secondary); }
.cf-modal { width: 620px; max-width: 94vw; }
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.cf-detail__row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.cf-hint { font-size: 12px; color: var(--fonu-text-muted); }
.cf-pre {
max-height: 240px;
overflow: auto;
padding: 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
+8 -8
View File
@@ -8,7 +8,7 @@
</template>
</PageHeader>
<n-alert type="success" :bordered="false" class="example-alert" title="v1.5.4 示例页面验证标识已生效">
<n-alert type="success" :bordered="false" class="example-alert" title="v1.6.0 示例页面验证标识已生效">
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong>
</div>
<div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.4。
看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.0。
</div>
</div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template>
</StatCard>
<StatCard label="页面状态" value="已更新" sub="v1.5.4 前端资源已加载" tone="teal">
<StatCard label="页面状态" value="已更新" sub="v1.6.0 前端资源已加载" tone="teal">
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard>
</div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes">
<li>页面版本提示更新为 v1.5.4。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.4。</li>
<li>验证标识更新为 <span class="mono">v1.5.4-example-page</span>。</li>
<li>页面版本提示更新为 v1.6.0。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.6.0。</li>
<li>验证标识更新为 <span class="mono">v1.6.0-example-page</span>。</li>
</ul>
</FonuCard>
@@ -87,8 +87,8 @@ const message = useMessage()
const version = ref(__APP_VERSION__)
const checkedAt = ref('')
const refreshing = ref(false)
const verificationRound = 'v1.5.4'
const verificationMarker = 'v1.5.4-example-page'
const verificationRound = 'v1.6.0'
const verificationMarker = 'v1.6.0-example-page'
async function refreshVersion() {
refreshing.value = true