chore(release): 发布 v1.9.2
CI / verify (push) Canceled after 0s
Docker Release / build-and-push (amd64, linux/amd64) (push) Canceled after 0s
Docker Release / build-and-push (arm64, linux/arm64) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Docker Release / create-manifest (push) Canceled after 0s
CI / verify (push) Canceled after 0s
Docker Release / build-and-push (amd64, linux/amd64) (push) Canceled after 0s
Docker Release / build-and-push (arm64, linux/arm64) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Docker Release / create-manifest (push) Canceled after 0s
- 「公网服务端」新增 Nginx 启停控制(Agent 0.15.2,停止带二次确认) - frps 启动失败不再误报成功,并增强启动诊断 - frps vhost 端口禁止 80/443,避免与 VPS 上 Nginx 冲突 - 旧服务端配置的 tcp_mux / login_fail_exit 默认值补齐 - 新增安全审查、页面布局方案与场景操作文档
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "havline-web",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const APP_BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||
|
||||
export function withBase(path: string): string {
|
||||
if (!path.startsWith('/')) return path
|
||||
return `${APP_BASE}${path}`
|
||||
}
|
||||
+30
-35
@@ -9,10 +9,6 @@ import type {
|
||||
DDNSConfig,
|
||||
DDNSSavePayload,
|
||||
DDNSTestPayload,
|
||||
FRPResponse,
|
||||
FRPSavePayload,
|
||||
FRPSaveResponse,
|
||||
FRPStatus,
|
||||
FrpBinary,
|
||||
FrpDownloadTask,
|
||||
FrpProxy,
|
||||
@@ -51,6 +47,7 @@ import type {
|
||||
CfPreflightResult,
|
||||
CfRouteTestResult,
|
||||
} from './types'
|
||||
import { withBase } from './base'
|
||||
|
||||
export function asList<T>(value: T[] | null | undefined): T[] {
|
||||
return Array.isArray(value) ? value : []
|
||||
@@ -58,6 +55,7 @@ export function asList<T>(value: T[] | null | undefined): T[] {
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000
|
||||
const AGENT_OPERATION_TIMEOUT_MS = 5 * 60_000
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
@@ -79,7 +77,7 @@ async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_
|
||||
}
|
||||
|
||||
async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: AbortSignal): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
const response = await fetch(withBase(path), {
|
||||
credentials: 'include',
|
||||
signal,
|
||||
headers: {
|
||||
@@ -179,7 +177,7 @@ export const api = {
|
||||
email: payload.email ?? '',
|
||||
}),
|
||||
}),
|
||||
certificateApplyStreamURL: (jobId: string) => `/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`,
|
||||
certificateApplyStreamURL: (jobId: string) => withBase(`/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`),
|
||||
importCertificate: (payload: {
|
||||
certificate?: string
|
||||
private_key?: string
|
||||
@@ -192,7 +190,7 @@ export const api = {
|
||||
}),
|
||||
async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') {
|
||||
const response = await fetch(
|
||||
`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`,
|
||||
withBase(`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`),
|
||||
{ credentials: 'include' },
|
||||
)
|
||||
if (!response.ok) {
|
||||
@@ -265,19 +263,6 @@ export const api = {
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
getFRP: () => request<FRPResponse>('/api/frp'),
|
||||
saveFRP: (payload: FRPSavePayload) =>
|
||||
request<FRPSaveResponse>('/api/frp', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
getFRPStatus: () => request<FRPStatus>('/api/frp/status'),
|
||||
syncFRPDomains: () =>
|
||||
request<FRPSaveResponse & { domains: string[] }>('/api/frp/sync-domains', {
|
||||
method: 'POST',
|
||||
}),
|
||||
getFRPLogs: (limit = 200) => request<string[]>(`/api/frp/logs?limit=${limit}`),
|
||||
|
||||
// ===== FRP 多服务端模块(/api/frpmulti)=====
|
||||
getFrpStatus: () => request<FrpStatus>('/api/frpmulti/status'),
|
||||
getFrpRuntime: () => request<FrpRuntimeInfo>('/api/frpmulti/runtime'),
|
||||
@@ -324,11 +309,11 @@ export const api = {
|
||||
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ transport, port }),
|
||||
}),
|
||||
}, AGENT_OPERATION_TIMEOUT_MS),
|
||||
getFrpAgentTransport: (id: number) =>
|
||||
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/transport`),
|
||||
rotateFrpAgentTLSCert: (id: number) =>
|
||||
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport/rotate`, { method: 'POST' }),
|
||||
request<FrpServer>(`/api/frpmulti/servers/${id}/agent/transport/rotate`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
getFrpAgentFirewall: (id: number, port: number) =>
|
||||
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/firewall?port=${port}`),
|
||||
allowFrpAgentFirewallPort: (id: number, port: number) =>
|
||||
@@ -336,17 +321,27 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ port }),
|
||||
}),
|
||||
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }),
|
||||
installFrpAgent: (id: number) => request<FrpServer>(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }),
|
||||
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
|
||||
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }),
|
||||
probeFrpAgent: (id: number) => request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
installFrpAgent: (id: number, trustNewHostKey = false) =>
|
||||
request<FrpServer>(
|
||||
`/api/frpmulti/servers/${id}/agent/install${trustNewHostKey ? '?trust_new_host_key=true' : ''}`,
|
||||
{ method: 'POST' },
|
||||
AGENT_OPERATION_TIMEOUT_MS,
|
||||
),
|
||||
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
|
||||
getFrpAgentCerts: (id: number) =>
|
||||
request<import('./types').FrpAgentCerts>(`/api/frpmulti/servers/${id}/agent/certs`),
|
||||
reloadFrpAgentNginx: (id: number) =>
|
||||
request<import('./types').FrpAgentNginxReload>(`/api/frpmulti/servers/${id}/agent/nginx/reload`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
}, AGENT_OPERATION_TIMEOUT_MS),
|
||||
actionFrpAgentNginx: (id: number, action: 'start' | 'stop' | 'restart') =>
|
||||
request<import('./types').FrpAgentNginxAction>(`/api/frpmulti/servers/${id}/agent/nginx/action`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action }),
|
||||
}, AGENT_OPERATION_TIMEOUT_MS),
|
||||
getFrpAgentMetrics: (id: number) =>
|
||||
request<import('./types').FrpAgentMetrics>(`/api/frpmulti/servers/${id}/agent/metrics`),
|
||||
getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) =>
|
||||
@@ -378,7 +373,7 @@ export const api = {
|
||||
{ method: 'POST' },
|
||||
),
|
||||
installFrpAgentNginx: (serverId: number) =>
|
||||
request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }),
|
||||
request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
getFrpAgentRouteHealth: (serverId: number) =>
|
||||
request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
|
||||
getFrpRouteHealthSummary: () =>
|
||||
@@ -396,13 +391,13 @@ export const api = {
|
||||
`/api/status-page${code ? `?code=${encodeURIComponent(code)}` : ''}`,
|
||||
),
|
||||
removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }),
|
||||
pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }),
|
||||
pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') =>
|
||||
request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }),
|
||||
request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
frpsServerActionRaw: (id: number, action: 'start' | 'stop' | 'restart') =>
|
||||
request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }),
|
||||
request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
installFrpServer: (id: number, version: string, proxy: string) =>
|
||||
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }),
|
||||
request<Record<string, any>>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }, AGENT_OPERATION_TIMEOUT_MS),
|
||||
|
||||
getAccessLogs: (params?: { limit?: number; keyword?: string; status?: number }) => {
|
||||
const q = new URLSearchParams()
|
||||
@@ -432,7 +427,7 @@ export const api = {
|
||||
},
|
||||
|
||||
async exportBackup() {
|
||||
const response = await fetch('/api/backup/export', { credentials: 'include' })
|
||||
const response = await fetch(withBase('/api/backup/export'), { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error('导出备份失败')
|
||||
}
|
||||
@@ -454,7 +449,7 @@ export const api = {
|
||||
deleteBackupArchive: (name: string) =>
|
||||
request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
async downloadBackupArchive(name: string) {
|
||||
const response = await fetch(`/api/backup/archives/${encodeURIComponent(name)}`, { credentials: 'include' })
|
||||
const response = await fetch(withBase(`/api/backup/archives/${encodeURIComponent(name)}`), { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error('下载备份失败')
|
||||
}
|
||||
@@ -470,7 +465,7 @@ export const api = {
|
||||
async restoreBackup(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await fetch('/api/backup/restore', {
|
||||
const response = await fetch(withBase('/api/backup/restore'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
|
||||
+13
-56
@@ -345,62 +345,6 @@ export interface DDNSTestPayload {
|
||||
api_secret?: string
|
||||
}
|
||||
|
||||
export interface FRPStatus {
|
||||
enabled: boolean
|
||||
connected: boolean
|
||||
message: string
|
||||
last_error?: string
|
||||
server_endpoint?: string
|
||||
connected_at?: string
|
||||
uptime_seconds?: number
|
||||
client_version?: string
|
||||
http_gateway_enabled?: boolean
|
||||
https_gateway_enabled?: boolean
|
||||
synced_domain_count?: number
|
||||
}
|
||||
|
||||
export interface FRPConfig {
|
||||
enabled: boolean
|
||||
server_addr: string
|
||||
server_port: number
|
||||
auth_token: string
|
||||
has_auth_token: boolean
|
||||
tls_enabled: boolean
|
||||
custom_domains: string[]
|
||||
}
|
||||
|
||||
export interface FRPResponse {
|
||||
enabled: boolean
|
||||
server_addr: string
|
||||
server_port: number
|
||||
auth_token: string
|
||||
has_auth_token: boolean
|
||||
tls_enabled: boolean
|
||||
custom_domains: string[]
|
||||
status: FRPStatus
|
||||
frps_config: string
|
||||
nginx_http_port: number
|
||||
nginx_https_port: number
|
||||
}
|
||||
|
||||
export interface FRPSaveResponse {
|
||||
message: string
|
||||
config: FRPConfig
|
||||
status: FRPStatus
|
||||
frps_config: string
|
||||
nginx_http_port: number
|
||||
nginx_https_port: number
|
||||
}
|
||||
|
||||
export interface FRPSavePayload {
|
||||
enabled: boolean
|
||||
server_addr: string
|
||||
server_port: number
|
||||
auth_token?: string
|
||||
tls_enabled: boolean
|
||||
custom_domains: string[]
|
||||
}
|
||||
|
||||
// ===== FRP 多服务端模块(/api/frpmulti,0.1.25 核心迁移)=====
|
||||
|
||||
export interface FrpServer {
|
||||
@@ -796,6 +740,14 @@ export interface FrpAgentNginxReload {
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export interface FrpAgentNginxAction {
|
||||
ok: boolean
|
||||
action: string
|
||||
nginx_running?: boolean
|
||||
nginx_ok?: boolean
|
||||
nginx_error?: string
|
||||
}
|
||||
|
||||
export interface FrpAgentMetrics {
|
||||
cpu_percent: number
|
||||
mem_used_bytes: number
|
||||
@@ -866,6 +818,11 @@ export interface FrpAgentRouteDetail {
|
||||
}
|
||||
|
||||
export interface FrpAgentStatus {
|
||||
configured?: boolean
|
||||
mgmt_enabled?: boolean
|
||||
token_mismatch?: boolean
|
||||
available?: boolean
|
||||
message?: string
|
||||
agent_version?: string
|
||||
listen_addr?: string
|
||||
listen_scope?: string
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<VChart class="chart" :option="option" autoresize />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { PieChart } from 'echarts/charts'
|
||||
import { LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
use([CanvasRenderer, PieChart, LegendComponent, TitleComponent, TooltipComponent])
|
||||
|
||||
const props = defineProps<{
|
||||
segments: { label: string; value: number; color: string }[]
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const option = computed(() => {
|
||||
const textColor = isDark.value ? '#94a3b8' : '#6b7280'
|
||||
const titleColor = isDark.value ? '#f1f5f9' : '#111827'
|
||||
const total = props.segments.reduce((sum, s) => sum + s.value, 0)
|
||||
|
||||
return {
|
||||
title: [
|
||||
{
|
||||
text: String(total),
|
||||
left: '31%',
|
||||
top: '38%',
|
||||
textAlign: 'center',
|
||||
textStyle: { fontSize: 22, fontWeight: 700, color: titleColor },
|
||||
},
|
||||
{
|
||||
text: '总请求',
|
||||
left: '31%',
|
||||
top: '52%',
|
||||
textAlign: 'center',
|
||||
textStyle: { fontSize: 12, color: textColor },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: isDark.value ? '#1e293b' : '#fff',
|
||||
borderColor: isDark.value ? '#334155' : '#e5e7eb',
|
||||
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
|
||||
formatter: '{b}: {c} ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 0,
|
||||
top: 'center',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
itemGap: 12,
|
||||
textStyle: { color: textColor, fontSize: 13 },
|
||||
formatter: (name: string) => {
|
||||
const seg = props.segments.find((s) => s.label === name)
|
||||
const total = props.segments.reduce((sum, s) => sum + s.value, 0) || 1
|
||||
const pct = seg ? Math.round((seg.value / total) * 100) : 0
|
||||
return `${name} ${pct}% (${seg?.value ?? 0})`
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['52%', '72%'],
|
||||
center: ['32%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
scale: true,
|
||||
scaleSize: 4,
|
||||
},
|
||||
data: props.segments.map((s) => ({
|
||||
name: s.label,
|
||||
value: s.value,
|
||||
itemStyle: { color: s.color },
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,6 @@
|
||||
export const GITHUB_REPO = 'O96u/Fonu'
|
||||
export const GITHUB_REPO = 'mrlee233/havline'
|
||||
export const UPSTREAM_REPO = 'O96u/Fonu'
|
||||
export const UPSTREAM_REPO_URL = `https://github.com/${UPSTREAM_REPO}`
|
||||
export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`
|
||||
export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}`
|
||||
export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`
|
||||
|
||||
@@ -17,7 +17,7 @@ import StatusPageView from '../views/StatusPageView.vue'
|
||||
import AboutView from '../views/AboutView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/setup', redirect: '/login' },
|
||||
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
||||
|
||||
+162
-14
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<PageHeader title="关于项目" description="了解 Havline 的项目定位、来源、能力与开源信息">
|
||||
<PageHeader title="关于项目" description="查看版本、项目仓库、发布记录与开源信息">
|
||||
<template #actions>
|
||||
<n-button :loading="refreshing" @click="refreshVersion">
|
||||
<template #icon><n-icon :component="RefreshOutline" /></template>
|
||||
@@ -15,18 +15,18 @@
|
||||
<p class="about-kicker">ABOUT HAVLINE</p>
|
||||
<h2>让每一台 NAS,都能安全地连接世界。</h2>
|
||||
<p>
|
||||
Havline 是一款面向飞牛 fnOS 等 NAS 环境的轻量公网访问管理工具。
|
||||
它把反向代理、DDNS、HTTPS 证书、内网穿透、公网 Agent 与 Cloudflare Tunnel
|
||||
收进一个 Web 控制台,让家庭与小型办公环境中的服务访问更简单、更可控。
|
||||
Havline 面向家庭 NAS 与自托管环境,集中管理服务的公网访问入口。
|
||||
从域名解析、HTTPS 证书到反向代理、FRP 和 Cloudflare 隧道,
|
||||
将分散的配置与日常运维集中到一处,支持容器化与本地化部署。
|
||||
</p>
|
||||
<div class="about-actions">
|
||||
<a :href="GITHUB_REPO_URL" target="_blank" rel="noopener noreferrer">
|
||||
<n-icon :component="LogoGithub" />
|
||||
查看源项目
|
||||
查看项目源码
|
||||
</a>
|
||||
<a :href="GITHUB_RELEASES_URL" target="_blank" rel="noopener noreferrer">
|
||||
<n-icon :component="CloudDownloadOutline" />
|
||||
Release
|
||||
发布版本
|
||||
</a>
|
||||
<a :href="GPL_URL" target="_blank" rel="noopener noreferrer">
|
||||
<n-icon :component="DocumentTextOutline" />
|
||||
@@ -42,7 +42,7 @@
|
||||
<strong class="mono">v{{ version }}</strong>
|
||||
</div>
|
||||
<div class="about-meta-item">
|
||||
<span>源项目</span>
|
||||
<span>项目仓库</span>
|
||||
<strong>{{ GITHUB_REPO }}</strong>
|
||||
</div>
|
||||
<div class="about-meta-item">
|
||||
@@ -56,6 +56,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="about-sections">
|
||||
<HavlineCard title="项目定位" subtitle="Havline 解决什么问题">
|
||||
<div class="about-lead">
|
||||
<p>
|
||||
@@ -75,7 +76,7 @@
|
||||
</article>
|
||||
<article class="about-point">
|
||||
<h3>保留可控性</h3>
|
||||
<p>所有配置、凭据和运行数据都保存在自己的数据目录中,不依赖外部托管平台。</p>
|
||||
<p>本地配置与运行数据由自己管理,支持配置回滚和备份恢复;DNS、证书与隧道按所选服务接入。</p>
|
||||
</article>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
@@ -85,13 +86,13 @@
|
||||
<blockquote>开源不是重新发明轮子,而是站在巨人的肩膀上,把一件事继续做得更好。</blockquote>
|
||||
<p>
|
||||
Havline 是基于开源项目
|
||||
<a :href="GITHUB_REPO_URL" target="_blank" rel="noopener noreferrer">{{ GITHUB_REPO }}</a>
|
||||
<a :href="UPSTREAM_REPO_URL" target="_blank" rel="noopener noreferrer">{{ UPSTREAM_REPO }}</a>
|
||||
<strong>1.0.2 版本</strong>进行二次开发的项目。原始项目已经完成了反向代理、DDNS、证书、
|
||||
内网穿透等核心能力,为 Havline 提供了坚实的工程基础。
|
||||
</p>
|
||||
<p>
|
||||
我们在原项目的基础上进行了品牌重塑、界面调整、功能迭代、部署流程整理和文档重构。
|
||||
无论项目名称如何变化,Havline 都保留对源项目和其贡献者的明确致谢,并继续遵循
|
||||
Havline 在此基础上持续完善多服务端管理、公网 Agent、Cloudflare 隧道和部署兼容性。
|
||||
当前源码与版本发布由 {{ GITHUB_REPO }} 维护,保留上游来源与贡献者致谢,并继续遵循
|
||||
<a :href="GPL_URL" target="_blank" rel="noopener noreferrer">GPL-3.0</a>
|
||||
开源协议。
|
||||
</p>
|
||||
@@ -112,6 +113,85 @@
|
||||
</div>
|
||||
</HavlineCard>
|
||||
|
||||
<HavlineCard title="适用场景" subtitle="根据网络条件选择服务入口">
|
||||
<div class="about-points">
|
||||
<article class="about-point">
|
||||
<h3>有公网访问条件的 NAS</h3>
|
||||
<p>通过 DDNS 跟随公网 IP,使用 Nginx 反向代理和 HTTPS 证书发布服务。实际访问仍取决于路由器转发、防火墙、运营商限制与 IPv4 / IPv6 连通性。</p>
|
||||
</article>
|
||||
<article class="about-point">
|
||||
<h3>通过公网服务器接入</h3>
|
||||
<p>使用 FRP 连接自有 VPS,并通过公网 Agent 管理服务器上的 Nginx、frps 和服务路由。VPS、域名与服务器权限由使用者自行准备和管理。</p>
|
||||
</article>
|
||||
<article class="about-point">
|
||||
<h3>不开放本机入站端口</h3>
|
||||
<p>通过 Cloudflare Tunnel 发布内网 Web 服务,管理隧道、域名路由与 DNS。此方式依赖 Cloudflare 账户、授权和网络连接,适用范围受其服务规则约束。</p>
|
||||
</article>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
|
||||
<HavlineCard title="部署与运行" subtitle="一份业务源码,多种交付方式">
|
||||
<div class="about-detail-grid">
|
||||
<article class="about-detail-item">
|
||||
<span class="about-detail-item__eyebrow">Docker</span>
|
||||
<h3>Compose 管理容器与数据</h3>
|
||||
<p>适合通用 Linux、NAS 和已有 Docker 环境。数据通过命名卷或绑定目录持久化,配有 updater 的 Compose 部署支持受控升级。</p>
|
||||
</article>
|
||||
<article class="about-detail-item">
|
||||
<span class="about-detail-item__eyebrow">自托管部署</span>
|
||||
<h3>按环境选择运行方式</h3>
|
||||
<p>可根据设备与运维习惯选择容器化或本地化部署,数据结构保持一致,便于迁移、备份和恢复。</p>
|
||||
</article>
|
||||
<article class="about-detail-item">
|
||||
<span class="about-detail-item__eyebrow">按需组件</span>
|
||||
<h3>首次使用时下载</h3>
|
||||
<p>FRP 客户端和 cloudflared 不预置在基础运行环境中,启用对应功能时需要联网下载并保存到数据目录。</p>
|
||||
</article>
|
||||
<article class="about-detail-item">
|
||||
<span class="about-detail-item__eyebrow">外部依赖</span>
|
||||
<h3>域名、证书与服务器由用户准备</h3>
|
||||
<p>DNS、ACME 证书、公网 VPS 和 Cloudflare Tunnel 需要相应账户、凭据或服务器,应用不会自动获得公网 IP 或第三方授权。</p>
|
||||
</article>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
|
||||
<HavlineCard title="数据与安全" subtitle="本地管理不等于无需防护">
|
||||
<div class="about-points about-points--security">
|
||||
<article class="about-point">
|
||||
<h3>完整备份,而非只备份数据库</h3>
|
||||
<p>数据库、会话与凭据加密密钥、证书私钥和运行配置共同组成实例数据。迁移或升级前应备份完整数据目录;使用环境变量提供密钥时,还需安全保留原值。</p>
|
||||
</article>
|
||||
<article class="about-point">
|
||||
<h3>管理入口与服务入口分离</h3>
|
||||
<p>对外发布业务服务时,不应顺带开放管理后台、Docker Socket 或 Agent 管理端口。按需配置认证、HTTPS、防火墙和访问限制,并定期检查授权范围。</p>
|
||||
</article>
|
||||
<article class="about-point">
|
||||
<h3>保护凭据与备份归档</h3>
|
||||
<p>DNS Token、服务器凭据和证书私钥属于敏感数据。凭据加密不能替代文件权限与备份保护;已有数据后不要随意更换加密主密钥,以免失去解密能力。</p>
|
||||
</article>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
|
||||
<HavlineCard title="项目维护与反馈" subtitle="以当前源码和发布记录为准">
|
||||
<div class="about-detail-grid about-detail-grid--feedback">
|
||||
<article class="about-detail-item">
|
||||
<span class="about-detail-item__eyebrow">源码与版本</span>
|
||||
<h3>跟随项目仓库发布</h3>
|
||||
<p>当前源码由 <a :href="GITHUB_REPO_URL" target="_blank" rel="noopener noreferrer">{{ GITHUB_REPO }}</a> 维护,已发布版本与更新说明见 <a :href="GITHUB_RELEASES_URL" target="_blank" rel="noopener noreferrer">发布记录</a>。生产环境请固定版本并保留备份。</p>
|
||||
</article>
|
||||
<article class="about-detail-item">
|
||||
<span class="about-detail-item__eyebrow">问题反馈</span>
|
||||
<h3>提供可复现的信息</h3>
|
||||
<p>通过 <a :href="`${GITHUB_REPO_URL}/issues`" target="_blank" rel="noopener noreferrer">GitHub Issues</a> 反馈时,请附版本、部署方式、设备架构、复现步骤和脱敏日志。</p>
|
||||
</article>
|
||||
<article class="about-detail-item about-detail-item--wide">
|
||||
<span class="about-detail-item__eyebrow">开源协作</span>
|
||||
<h3>保留来源,持续改进</h3>
|
||||
<p>欢迎提交问题复现、兼容性反馈和代码改进。Havline 保留上游贡献者的版权与来源说明,持续按 GPL-3.0 发布;请勿上传密码、Token、私钥、完整数据库或包含敏感配置的备份归档。</p>
|
||||
</article>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
|
||||
<HavlineCard title="技术栈" subtitle="保持简单、透明、易部署">
|
||||
<div class="about-tech">
|
||||
<section v-for="group in techGroups" :key="group.title" class="about-tech__group">
|
||||
@@ -135,8 +215,8 @@
|
||||
<div class="about-license__item">
|
||||
<n-icon :component="LogoGithub" />
|
||||
<div>
|
||||
<h3>源项目保留</h3>
|
||||
<p>源项目地址固定为 {{ GITHUB_REPO }},用于追溯上游来源和许可证义务。</p>
|
||||
<h3>上游来源</h3>
|
||||
<p>基于 {{ UPSTREAM_REPO }} 继续开发,保留原作者版权、来源说明与许可证。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-license__item">
|
||||
@@ -148,6 +228,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -174,6 +255,8 @@ import {
|
||||
GITHUB_RELEASES_URL,
|
||||
GITHUB_REPO,
|
||||
GITHUB_REPO_URL,
|
||||
UPSTREAM_REPO,
|
||||
UPSTREAM_REPO_URL,
|
||||
} from '../constants/app'
|
||||
import { formatDate } from '../utils/format'
|
||||
|
||||
@@ -376,6 +459,16 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.about-sections {
|
||||
display: grid;
|
||||
gap: var(--havline-space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.about-sections > :deep(.havline-card) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.about-lead {
|
||||
max-width: 860px;
|
||||
color: var(--havline-text-secondary);
|
||||
@@ -394,6 +487,56 @@ onMounted(() => {
|
||||
margin-top: var(--havline-space-5);
|
||||
}
|
||||
|
||||
.about-points--security {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.about-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--havline-space-4);
|
||||
}
|
||||
|
||||
.about-detail-item {
|
||||
min-width: 0;
|
||||
padding: var(--havline-space-4);
|
||||
border: 1px solid var(--havline-border);
|
||||
border-radius: var(--havline-radius-sm);
|
||||
background: var(--havline-bg);
|
||||
}
|
||||
|
||||
.about-detail-item--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.about-detail-item__eyebrow {
|
||||
display: block;
|
||||
margin-bottom: var(--havline-space-2);
|
||||
color: var(--havline-brand-text);
|
||||
font-family: var(--havline-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.about-detail-item h3 {
|
||||
margin: 0 0 var(--havline-space-2);
|
||||
color: var(--havline-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.about-detail-item p {
|
||||
margin: 0;
|
||||
color: var(--havline-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.about-detail-item a {
|
||||
color: var(--havline-brand-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.about-point {
|
||||
padding-left: var(--havline-space-4);
|
||||
border-left: 2px solid var(--havline-brand);
|
||||
@@ -556,10 +699,15 @@ onMounted(() => {
|
||||
|
||||
.about-points,
|
||||
.about-features,
|
||||
.about-license {
|
||||
.about-license,
|
||||
.about-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about-detail-item--wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.about-tech__group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--havline-space-2);
|
||||
|
||||
+206
-25
@@ -215,6 +215,25 @@
|
||||
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
|
||||
<p v-if="reloadHint" class="form-hint">{{ reloadHint }}</p>
|
||||
<div class="agent-actions">
|
||||
<n-popconfirm
|
||||
v-if="agentStatus?.nginx_running"
|
||||
positive-text="停止"
|
||||
negative-text="取消"
|
||||
@positive-click="nginxAction('stop')"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-button size="small" :loading="actingNginx" :disabled="busy !== null">停止 Nginx</n-button>
|
||||
</template>
|
||||
停止后 VPS 上所有经 Nginx 反代的域名会立即无法访问,需要重新启动才能恢复。确定停止?
|
||||
</n-popconfirm>
|
||||
<n-button
|
||||
v-else
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="actingNginx"
|
||||
:disabled="busy !== null || !agentStatus?.nginx_path"
|
||||
@click="nginxAction('start')"
|
||||
>启动 Nginx</n-button>
|
||||
<n-button size="small" :loading="reloadingNginx" :disabled="busy !== null" @click="reloadNginx">重载 Nginx</n-button>
|
||||
<router-link to="/public-proxy"><n-button size="small" quaternary>去「公网反代」处理</n-button></router-link>
|
||||
</div>
|
||||
@@ -241,7 +260,7 @@
|
||||
<HavlineCard class="section-card" title="Agent 生命周期(SSH)">
|
||||
<div class="section-body">
|
||||
<p class="form-hint">
|
||||
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动建立 SSH 隧道,不会默认暴露公网 7700。
|
||||
首次安装、升级、卸载需要 SSH(在本页「编辑服务端 → 远程管理 → 公网 Agent 管理」区填写);安装成功后自动建立 SSH 隧道,不会默认暴露公网 7700。
|
||||
SSH 信息仅用于生命周期和隧道,凭据加密存储、不回显。
|
||||
</p>
|
||||
<div class="field-grid agent-transport-grid">
|
||||
@@ -307,7 +326,7 @@
|
||||
<n-button size="small" quaternary :disabled="!dashboardURL" @click="openDashboard">frps 管理面板</n-button>
|
||||
</span>
|
||||
</template>
|
||||
未配置 frps 管理接口:在「内网穿透」页服务端表单的「frps 管理接口」填写地址与端口后保存
|
||||
未配置 frps 管理接口:在本页「编辑服务端 → 服务端配置 → frps 管理接口」填写地址与端口后保存
|
||||
</n-tooltip>
|
||||
<n-button size="small" :loading="busy === 'push-config'" :disabled="busy !== null && busy !== 'push-config'" @click="pushAgentConfig(server)">下发 frps 配置</n-button>
|
||||
<n-button size="small" quaternary :loading="configLoading" :disabled="busy !== null" @click="viewConfig(server)">配置查看</n-button>
|
||||
@@ -403,6 +422,7 @@
|
||||
<div class="proxy-modal__tabbar">
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'basic' }" @click="serverFormTab = 'basic'">基础配置</button>
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'advanced' }" @click="serverFormTab = 'advanced'">高级配置</button>
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'server' }" @click="serverFormTab = 'server'">服务端配置</button>
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': serverFormTab === 'remote' }" @click="serverFormTab = 'remote'">远程管理</button>
|
||||
</div>
|
||||
<div class="proxy-modal__scroll">
|
||||
@@ -415,6 +435,7 @@
|
||||
<n-form-item label="主机地址" required><n-input v-model:value="serverForm.server_addr" placeholder="例如:frps.example.com" /></n-form-item>
|
||||
<n-form-item label="端口" required><n-input-number v-model:value="serverForm.server_port" :min="1" :max="65535" /></n-form-item>
|
||||
</div>
|
||||
<div class="switch-row"><span>启用服务端</span><n-switch v-model:value="serverForm.enabled" /></div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
@@ -461,10 +482,10 @@
|
||||
<n-form-item label="描述"><n-input v-model:value="serverForm.options.remark" type="textarea" :rows="2" placeholder="添加一些关于此服务器的备注" /></n-form-item>
|
||||
</section>
|
||||
</div>
|
||||
<div v-show="serverFormTab === 'remote'" class="frp-server-modal__remote">
|
||||
<div v-show="serverFormTab === 'server'" class="frp-server-modal__server">
|
||||
<section class="form-section">
|
||||
<h3><n-icon :component="SwapHorizontalOutline" /> frps vhost 反代端口</h3>
|
||||
<p class="form-hint">frps 的 vhostHTTPPort / vhostHTTPSPort,即 Nginx 反代的回源端口(proxy_pass http://127.0.0.1:端口)。修改后需「下发 frps 配置」并重启 frps,且重新部署各规则的反代。留空用默认 8080 / 8443。</p>
|
||||
<h3><n-icon :component="SwapHorizontalOutline" /> frps vhost 与域名反代</h3>
|
||||
<p class="form-hint">frps 的 vhostHTTPPort / vhostHTTPSPort 是 Nginx 反代的回源端口(proxy_pass http://127.0.0.1:端口),同时包含 subDomainHost、tcpmux 等域名入口配置。修改后需「下发 frps 配置」并重启 frps,且重新部署各规则的反代。留空用默认 8080 / 8443。</p>
|
||||
<n-alert
|
||||
v-if="serverForm.options.vhost_http_port === 80 || serverForm.options.vhost_https_port === 443"
|
||||
type="warning"
|
||||
@@ -481,10 +502,6 @@
|
||||
<n-form-item label="子域名根(subDomainHost)"><n-input v-model:value="serverForm.options.sub_domain_host" placeholder="例如:frps.example.com,规则填 subdomain 后生成为 sub.frps.example.com" /></n-form-item>
|
||||
<n-form-item label="tcpmux 监听端口"><n-input-number v-model:value="serverForm.options.tcpmux_http_connect_port" :min="1" :max="65535" placeholder="存在 tcpmux 规则时必填" /></n-form-item>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<n-form-item label="端口白名单(allowPorts)"><n-input v-model:value="serverForm.options.allow_ports" placeholder="如 6000,6005-6010,7000-8000;留空不限制" /></n-form-item>
|
||||
<n-form-item label="每客户端端口上限"><n-input-number v-model:value="serverForm.options.max_ports_per_client" :min="0" :max="65535" placeholder="0 表示不限" /></n-form-item>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<n-form-item label="自定义 404 页面(VPS 路径)"><n-input v-model:value="serverForm.options.custom_404_page" placeholder="例如 /var/www/404.html;留空用默认" /></n-form-item>
|
||||
<div class="switch-row"><span>强制 TLS(frps 只接受 TLS 连接)</span><n-switch v-model:value="serverForm.options.tls_force" /></div>
|
||||
@@ -493,6 +510,15 @@
|
||||
<n-form-item v-if="serverForm.options.frps_log_to_file" label="日志保留天数"><n-input-number v-model:value="serverForm.options.frps_log_max_days" :min="1" :max="365" placeholder="默认 3" class="narrow-input" /></n-form-item>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3><n-icon :component="GitNetworkOutline" /> TCP/UDP 端口分配</h3>
|
||||
<p class="form-hint">allowPorts 只限制 tcp / udp 规则使用的 remotePort,不控制 HTTP/HTTPS 的 vhost 回源端口。留空表示不限制,但实际远程端口仍需在云安全组和 VPS 主机防火墙放行。</p>
|
||||
<div class="form-grid">
|
||||
<n-form-item label="端口白名单(allowPorts)"><n-input v-model:value="serverForm.options.allow_ports" placeholder="如 6000,6005-6010,7000-8000;留空不限制" /></n-form-item>
|
||||
<n-form-item label="每客户端端口上限"><n-input-number v-model:value="serverForm.options.max_ports_per_client" :min="0" :max="65535" placeholder="0 表示不限" /></n-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3><n-icon :component="ServerOutline" /> frps 管理接口</h3>
|
||||
<p class="form-hint">用于 Havline 拉取穿透规则的实时连接与流量信息;需在 frps 的 frps.toml 中开启 webServer(dashboard),与 frpc 连接用的令牌无关。</p>
|
||||
@@ -513,18 +539,19 @@
|
||||
</div>
|
||||
<n-button v-if="editingServer?.dashboard_has_pwd" size="small" quaternary type="error" @click="serverForm.clear_dashboard_password = true">清除管理密码</n-button>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<div v-show="serverFormTab === 'remote'" class="frp-server-modal__remote">
|
||||
<section class="form-section">
|
||||
<h3><n-icon :component="CloudOutline" /> 公网 Agent 管理</h3>
|
||||
<p class="form-hint">Agent 部署在公网 VPS 上,保存 HTTP/HTTPS 规则后可由 Agent 自动生成反代;「公网 Agent」页提供完整管理面板。首次安装/升级需要 SSH,日常管理走 Agent API。</p>
|
||||
<p class="form-hint">Agent 部署在公网 VPS 上,保存 HTTP/HTTPS 规则后可由 Agent 自动生成反代;「公网 Agent」页提供完整管理面板。首次安装/升级需要 SSH,日常管理走 Agent API。Agent 地址通常由 SSH 隧道或 HTTPS 固定自动回填,不建议将 7700 明文暴露到公网。</p>
|
||||
<div class="form-grid">
|
||||
<n-form-item label="Agent API 地址"><n-input v-model:value="serverForm.agent_url" placeholder="例如:http://vps.example.com:7700" /></n-form-item>
|
||||
<n-form-item label="Agent API 地址"><n-input v-model:value="serverForm.agent_url" placeholder="安装后自动回填;也可填 http://127.0.0.1:7700(SSH 隧道)" /></n-form-item>
|
||||
<n-form-item :show-label="false" :show-feedback="false"><ConfiguredSecretField v-model="serverForm.agent_token" label="Agent Token" :configured="!!editingServer?.agent_configured" placeholder="留空保持不变" /></n-form-item>
|
||||
<n-form-item label="SSH 地址"><n-input v-model:value="serverForm.ssh_host" placeholder="用于安装/升级/卸载 agent" /></n-form-item>
|
||||
<n-form-item label="SSH 端口"><n-input-number v-model:value="serverForm.ssh_port" :min="1" :max="65535" /></n-form-item>
|
||||
<n-form-item label="SSH 用户"><n-input v-model:value="serverForm.ssh_user" placeholder="root 或具备 sudo 权限的用户" /></n-form-item>
|
||||
<n-form-item label="SSH 认证"><n-select v-model:value="serverForm.ssh_auth" :options="[{ label: '私钥', value: 'key' }, { label: '密码', value: 'password' }]" /></n-form-item>
|
||||
<n-form-item :show-label="false" :show-feedback="false"><ConfiguredSecretField v-model="serverForm.ssh_secret" label="SSH 密码/私钥" :configured="!!editingServer?.ssh_configured" placeholder="用于 Agent 安装与升级" /></n-form-item>
|
||||
<n-form-item :show-label="false" :show-feedback="false"><ConfiguredSecretField v-model="serverForm.ssh_secret" :label="serverForm.ssh_auth === 'key' ? 'SSH 私钥' : 'SSH 密码'" :configured="!!editingServer?.ssh_configured" :placeholder="serverForm.ssh_auth === 'key' ? '粘贴私钥内容,用于 Agent 安装与升级' : '输入 SSH 密码,用于 Agent 安装与升级'" /></n-form-item>
|
||||
</div>
|
||||
<div class="switch-row"><span>启用 Agent 日常管理</span><n-switch v-model:value="serverForm.mgmt_enabled" /></div>
|
||||
<div v-if="editingServer?.agent_configured" class="agent-actions">
|
||||
@@ -532,6 +559,7 @@
|
||||
<n-button size="small" :loading="busy === 'push-config'" :disabled="busy !== null && busy !== 'push-config'" @click="pushAgentConfig(editingServer)">下发 frps 配置</n-button>
|
||||
<n-button size="small" quaternary @click="showServerModal = false; selectedId = editingServer.id">查看服务端详情</n-button>
|
||||
</div>
|
||||
<p v-if="editingServer?.agent_configured" class="form-hint">「下发 frps 配置」使用已保存的配置;若刚修改了上方参数,请先保存再下发。</p>
|
||||
</section>
|
||||
</div>
|
||||
<div v-show="serverFormTab === 'advanced'" class="frp-server-modal__advanced">
|
||||
@@ -593,7 +621,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-modal__help">
|
||||
<h4>{{ serverFormTab === 'basic' ? '配置说明' : '高级配置说明' }}</h4>
|
||||
<h4>{{ serverHelpTitle }}</h4>
|
||||
<ol>
|
||||
<li v-for="item in serverHelpItems" :key="item.title"><strong>{{ item.title }}</strong>:{{ item.text }}</li>
|
||||
</ol>
|
||||
@@ -799,6 +827,8 @@ const headerDescription = computed(() => {
|
||||
// 三态:未加载 / 加载中 / 失败——避免把"没拉到"显示成"未连接"
|
||||
const agentConnectionText = computed(() => {
|
||||
if (!statusLoaded.value) return statusLoading.value ? '读取中…' : '—'
|
||||
if (agentStatus.value?.configured === false) return '未配置'
|
||||
if (agentStatus.value?.token_mismatch) return 'Token 不匹配'
|
||||
return agentStatusOk.value ? '在线' : '未连接'
|
||||
})
|
||||
const frpsStateText = computed(() => {
|
||||
@@ -827,6 +857,27 @@ const statusAlert = computed<{ type: 'error' | 'warning'; text: string; action?:
|
||||
const current = server.value
|
||||
if (!current || !statusLoaded.value) return null
|
||||
if (!agentStatusOk.value) {
|
||||
if (agentStatus.value?.configured === false) {
|
||||
return {
|
||||
type: 'warning',
|
||||
text: '尚未安装 Agent:请先填写 SSH 信息,再点击下方「安装 Agent」。',
|
||||
action: { label: '安装 Agent', run: () => installAgent(current) },
|
||||
}
|
||||
}
|
||||
if (agentStatus.value?.mgmt_enabled === false) {
|
||||
return {
|
||||
type: 'warning',
|
||||
text: 'Agent 已配置,但「启用 Agent 日常管理」未开启。',
|
||||
action: { label: '编辑服务端', run: () => openServerModal(current) },
|
||||
}
|
||||
}
|
||||
if (agentStatus.value?.token_mismatch) {
|
||||
return {
|
||||
type: 'error',
|
||||
text: 'Agent Token 不匹配:Havline 保存的 Token 与 VPS 上的 Agent 配置不一致。',
|
||||
action: { label: '升级 Agent', run: () => installAgent(current) },
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'error',
|
||||
text: 'Agent 未连接:请确认 VPS 上 havline-agent 正在运行,且 Agent 地址与 Token 与页面一致。',
|
||||
@@ -998,6 +1049,8 @@ async function loadHavlineCerts() {
|
||||
// Nginx 显式重载(需 agent ≥ 0.11.0):把校验 / 重载失败原因直接显示出来
|
||||
const reloadingNginx = ref(false)
|
||||
const reloadHint = ref('')
|
||||
// Nginx 启停(需 agent ≥ 0.15.2):按钮文案随进程状态切换
|
||||
const actingNginx = ref(false)
|
||||
|
||||
async function reloadNginx() {
|
||||
const target = server.value
|
||||
@@ -1020,18 +1073,42 @@ async function reloadNginx() {
|
||||
}
|
||||
}
|
||||
|
||||
// 启停后刷新状态,让卡片上的「进程」与按钮文案同步
|
||||
async function nginxAction(action: 'start' | 'stop' | 'restart') {
|
||||
const target = server.value
|
||||
if (!target) return
|
||||
actingNginx.value = true
|
||||
reloadHint.value = ''
|
||||
try {
|
||||
const result = await api.actionFrpAgentNginx(target.id, action)
|
||||
await refreshAll({ silent: true })
|
||||
reloadHint.value = result.nginx_running ? 'Nginx 已启动' : 'Nginx 已停止'
|
||||
} catch (error) {
|
||||
reloadHint.value = error instanceof Error ? error.message : 'Nginx 操作失败'
|
||||
} finally {
|
||||
actingNginx.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询走静默(不触碰 busy);首次加载显示 statusLoading
|
||||
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
if (options.silent === false) statusLoading.value = true
|
||||
try {
|
||||
agentStatus.value = await api.getFrpAgentStatus(current.id)
|
||||
agentStatusOk.value = true
|
||||
const status = await api.getFrpAgentStatus(current.id)
|
||||
agentStatus.value = status
|
||||
agentStatusOk.value = status.configured !== false && status.mgmt_enabled !== false && status.token_mismatch !== true
|
||||
statusLoaded.value = true
|
||||
// 资源采集单独走,不阻断状态加载
|
||||
void loadMetrics(current.id)
|
||||
void loadAgentCerts(current.id)
|
||||
if (agentStatusOk.value) {
|
||||
// 资源采集单独走,不阻断状态加载
|
||||
void loadMetrics(current.id)
|
||||
void loadAgentCerts(current.id)
|
||||
} else {
|
||||
agentMetrics.value = null
|
||||
metricsError.value = ''
|
||||
havlineCertDomains.value = new Set()
|
||||
}
|
||||
} catch (error) {
|
||||
agentStatusOk.value = false
|
||||
statusLoaded.value = true
|
||||
@@ -1048,9 +1125,39 @@ function probe(target: FrpServer) {
|
||||
}))
|
||||
}
|
||||
|
||||
function isSSHHostKeyChangedError(error: unknown) {
|
||||
return error instanceof Error && error.message.includes('SSH 主机密钥与首次记录不一致')
|
||||
}
|
||||
|
||||
function confirmTrustSSHHostKey(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
dialog.warning({
|
||||
title: 'SSH 主机密钥已变化',
|
||||
content: '该服务器可能已重装系统。确认新主机密钥可信后,Havline 将更新记录并重新安装 Agent。',
|
||||
positiveText: '信任并重装',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => resolve(true),
|
||||
onNegativeClick: () => resolve(false),
|
||||
onClose: () => resolve(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function installAgentWithTrust(target: FrpServer) {
|
||||
try {
|
||||
return await api.installFrpAgent(target.id)
|
||||
} catch (error) {
|
||||
if (!isSSHHostKeyChangedError(error)) throw error
|
||||
if (!(await confirmTrustSSHHostKey())) {
|
||||
throw new Error('已取消信任新的 SSH 主机密钥')
|
||||
}
|
||||
return api.installFrpAgent(target.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
function installAgent(target: FrpServer) {
|
||||
return run('agent-install', target.agent_configured ? '升级 Agent' : '安装 Agent',
|
||||
() => api.installFrpAgent(target.id),
|
||||
() => installAgentWithTrust(target),
|
||||
() => ({ text: 'Agent 安装/升级完成,已自动回填 Agent 地址与 Token', ok: true }))
|
||||
.then(async (result) => { if (result !== undefined) await load() })
|
||||
}
|
||||
@@ -1209,7 +1316,7 @@ async function compareFrpsConfig() {
|
||||
const frpsConfigTarget = ref<FrpServer | null>(null)
|
||||
const frpsConfigLoading = ref(false)
|
||||
|
||||
const serverFormTab = ref<'basic' | 'advanced' | 'remote'>('basic')
|
||||
const serverFormTab = ref<'basic' | 'advanced' | 'server' | 'remote'>('basic')
|
||||
|
||||
const editingServer = ref<FrpServer | null>(null)
|
||||
const viewingServer = ref<FrpServer | null>(null)
|
||||
@@ -1229,12 +1336,14 @@ function defaultServerOptions(): FrpServerOptions {
|
||||
dial_server_keepalive: 7200, pool_count: 0, tcp_mux: true, tcp_mux_keepalive_interval: 60,
|
||||
heartbeat_interval: 30, heartbeat_timeout: 90, login_fail_exit: true, tls_disable_custom_first_byte: false, auto_start: false,
|
||||
dashboard_addr: '', dashboard_port: 0, dashboard_user: '',
|
||||
sub_domain_host: '', custom_404_page: '', tls_force: false, allow_ports: '', max_ports_per_client: 0,
|
||||
frps_log_to_file: false, frps_log_max_days: 3,
|
||||
}
|
||||
}
|
||||
|
||||
const serverForm = reactive({
|
||||
name: '', server_addr: '', server_port: 7000, tls_enabled: true, tls_server_name: '', enabled: true, token: '',
|
||||
agent_url: '', agent_token: '', ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_auth: 'key', ssh_secret: '', mgmt_enabled: false,
|
||||
agent_url: '', agent_token: '', ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_auth: 'key', ssh_secret: '', mgmt_enabled: true,
|
||||
oidc_client_secret: '', dashboard_password: '', clear_dashboard_password: false, tls_certificate: '', tls_key: '', tls_trusted_ca: '', clear_tls_certificate: false,
|
||||
clear_tls_key: false, clear_tls_trusted_ca: false, options: defaultServerOptions(),
|
||||
})
|
||||
@@ -1246,17 +1355,27 @@ const serverHelpItems = computed(() => {
|
||||
{ title: 'TLS', text: '默认启用;证书与密钥用于双向认证,受信任的 CA 用于校验 frps 证书,SNI 与 frps 侧证书域名一致。' },
|
||||
{ title: '连接调优', text: '心跳与 TCP 多路复用保持默认即可;网络不稳定时可加大超时,连接池可降低高并发时的建连延迟。' },
|
||||
]
|
||||
if (serverFormTab.value === 'server') return [
|
||||
{ title: 'frps vhost 与域名反代', text: 'vhostHTTPPort/vhostHTTPSPort 是 Nginx 反代的回源端口,同时决定 subDomainHost、tcpmux 等域名入口;留空用默认 8080/8443,80/443 会与 VPS 上的 Nginx 冲突。修改后需「下发 frps 配置」并重启 frps,再重新部署各规则。' },
|
||||
{ title: 'TCP/UDP 端口分配', text: 'allowPorts 只限制 tcp/udp 规则使用的 remotePort,不控制 vhost 回源端口;实际端口仍需在云安全组与 VPS 主机防火墙放行。' },
|
||||
{ title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Havline 借此拉取穿透规则的实时连接与流量;地址与端口需成对填写,与连接认证无关。' },
|
||||
]
|
||||
if (serverFormTab.value === 'remote') return [
|
||||
{ title: '公网 Agent 管理', text: 'Agent 部署在 VPS 上,保存 HTTP/HTTPS 规则后自动生成反代;首次安装/升级需 SSH,日常管理走 Agent API,Token 与 SSH 凭据加密存储、不回显。' },
|
||||
]
|
||||
const common = [
|
||||
{ title: '基本信息', text: '主机地址和端口对应 frps 的 bindAddr / bindPort,多个服务端可并行配置,规则保存时选择归属。' },
|
||||
{ title: '认证方式', text: '与 frps 的 auth 配置保持一致:无认证、令牌(auth.token)或 OIDC;令牌与密钥加密存储,编辑时留空保持不变。' },
|
||||
{ title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Havline 借此拉取穿透规则的实时连接与流量;不写入 frpc.toml,与连接认证无关。' },
|
||||
{ title: '描述', text: '仅用于在 Havline 中区分多台服务端的备注信息。' },
|
||||
]
|
||||
if (serverForm.options.auth_method === 'oidc') return [...common, { title: 'OIDC', text: '客户端 ID、密钥和令牌端点由身份提供商提供;受众与权限范围需与 frps 的 OIDC 校验配置匹配。' }]
|
||||
return common
|
||||
})
|
||||
const serverHelpTitle = computed(() => ({ basic: '配置说明', advanced: '高级配置说明', server: '服务端配置说明', remote: '远程管理说明' }[serverFormTab.value]))
|
||||
const serverHelpTip = computed(() => {
|
||||
if (serverFormTab.value === 'advanced') return '传输参数需与 frps 服务端配置匹配;修改后请点击“校验并应用配置”。'
|
||||
if (serverFormTab.value === 'advanced') return '传输参数需与 frps 服务端配置匹配;修改后保存即可生效。'
|
||||
if (serverFormTab.value === 'server') return 'frps 参数修改后需保存并「下发 frps 配置」,再重启 frps 才会生效。'
|
||||
if (serverFormTab.value === 'remote') return '首次安装/升级需 SSH;安装成功后建议使用 SSH 隧道或 HTTPS 固定接入,不要长期暴露公网 7700。'
|
||||
if (serverForm.options.auth_method === 'oidc') return 'OIDC 需要身份提供商支持客户端凭证模式,且 frps 侧已启用相同的 OIDC 校验。'
|
||||
return '请先在 VPS 上部署 frps 并放行对应端口,再保存服务端配置。'
|
||||
})
|
||||
@@ -1333,10 +1452,12 @@ function statusBarTooltip(sample: FrpServerDetail['status']['samples'][number])
|
||||
function resetServerForm() {
|
||||
Object.assign(serverForm, {
|
||||
name: '', server_addr: '', server_port: 7000, tls_enabled: true, tls_server_name: '', enabled: true, token: '',
|
||||
agent_url: '', agent_token: '', ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_auth: 'key', ssh_secret: '', mgmt_enabled: true,
|
||||
oidc_client_secret: '', dashboard_password: '', clear_dashboard_password: false, tls_certificate: '', tls_key: '', tls_trusted_ca: '', clear_tls_certificate: false,
|
||||
clear_tls_key: false, clear_tls_trusted_ca: false,
|
||||
})
|
||||
Object.assign(serverForm.options, defaultServerOptions())
|
||||
// 整体替换 options,避免上一次编辑的字段(如 vhost/日志/端口)残留到新增表单
|
||||
serverForm.options = defaultServerOptions()
|
||||
}
|
||||
|
||||
function openServerModal(server?: FrpServer) {
|
||||
@@ -1535,7 +1656,67 @@ function serverStateTooltip(server: FrpServer) {
|
||||
return `期望:${serverStartupText(server)};frpc:${processStateText(server)};FRP:${connectionStateText(server)}${server.last_error ? `;${server.last_error}` : ''}`
|
||||
}
|
||||
|
||||
function validateServerForm(): string {
|
||||
const form = serverForm
|
||||
if (!form.name.trim()) return '请填写服务端名称'
|
||||
if (!form.server_addr.trim()) return '请填写主机地址'
|
||||
if (!form.server_port || form.server_port < 1 || form.server_port > 65535) return '端口需在 1-65535 之间'
|
||||
|
||||
if (form.options.auth_method === 'token' && !editingServer.value && !form.token.trim()) {
|
||||
return '认证方式为令牌时,请填写与 frps 一致的令牌'
|
||||
}
|
||||
if (form.options.auth_method === 'oidc') {
|
||||
if (!form.options.oidc_client_id?.trim()) return 'OIDC 需填写客户端 ID'
|
||||
if (!form.options.oidc_token_endpoint_url?.trim()) return 'OIDC 需填写令牌端点'
|
||||
if (!editingServer.value && !form.oidc_client_secret.trim()) return 'OIDC 需填写客户端密钥'
|
||||
}
|
||||
|
||||
if (form.options.vhost_http_port === 80 || form.options.vhost_https_port === 443) {
|
||||
return 'frps 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用),请改为 8080/8443'
|
||||
}
|
||||
|
||||
const ports: Array<[string, number]> = [
|
||||
['控制端口', form.server_port],
|
||||
['HTTP 回源端口', form.options.vhost_http_port || 8080],
|
||||
['HTTPS 回源端口', form.options.vhost_https_port || 8443],
|
||||
]
|
||||
if (form.options.dashboard_port) ports.push(['管理端口', form.options.dashboard_port])
|
||||
if (form.options.tcpmux_http_connect_port) ports.push(['tcpmux 端口', form.options.tcpmux_http_connect_port])
|
||||
const seen = new Map<number, string>()
|
||||
for (const [label, port] of ports) {
|
||||
const prev = seen.get(port)
|
||||
if (prev) return `${prev}与${label}不能使用同一端口(${port})`
|
||||
seen.set(port, label)
|
||||
}
|
||||
|
||||
const hasDashboardAddr = !!form.options.dashboard_addr?.trim()
|
||||
const hasDashboardPort = !!form.options.dashboard_port
|
||||
if (hasDashboardAddr !== hasDashboardPort) return 'frps 管理接口的地址与端口需同时填写或同时留空'
|
||||
|
||||
if (form.ssh_host.trim()) {
|
||||
if (!form.ssh_user.trim()) return '填写了 SSH 地址后,需同时填写 SSH 用户'
|
||||
if (!editingServer.value?.ssh_configured && !form.ssh_secret.trim()) return '填写了 SSH 地址后,需填写 SSH 密码或私钥'
|
||||
}
|
||||
|
||||
if (form.options.tls_force && !form.tls_enabled) return '已勾选「强制 TLS」,请在高级配置中启用 TLS'
|
||||
|
||||
const allowPorts = form.options.allow_ports?.trim()
|
||||
if (allowPorts && !/^\d+(-\d+)?(\s*,\s*\d+(-\d+)?)*$/.test(allowPorts)) {
|
||||
return '端口白名单格式应为 6000,6005-6010 这样的单端口或范围'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function saveServer() {
|
||||
const invalid = validateServerForm()
|
||||
if (invalid) {
|
||||
message.error(invalid)
|
||||
return
|
||||
}
|
||||
if (serverForm.mgmt_enabled && !serverForm.agent_url.trim()) {
|
||||
message.warning('未填写 Agent 地址:保存后可在本页通过 SSH 安装 Agent 自动回填')
|
||||
}
|
||||
savingServer.value = true
|
||||
try {
|
||||
const payload: FrpServerPayload = {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<LoadError v-if="loadError" :message="loadError" @retry="init" />
|
||||
|
||||
<n-alert
|
||||
v-if="!loadError && frpEnabled"
|
||||
v-if="!loadError && frpEnabled && !bypassFrpHint"
|
||||
class="frp-ddns-alert"
|
||||
type="info"
|
||||
:bordered="false"
|
||||
@@ -19,6 +19,7 @@
|
||||
>
|
||||
请将域名 DNS 解析指向 <strong>FRP 服务器(VPS)的公网 IP</strong>,而非 NAS 本机 IP。可在
|
||||
<router-link :to="{ name: 'frp' }">内网穿透</router-link> 页管理 FRP 配置。
|
||||
<n-button size="small" type="primary" @click="bypassFrpHint = true">仍然进入 DDNS 设置</n-button>
|
||||
</n-alert>
|
||||
|
||||
<template v-else-if="!loadError">
|
||||
@@ -528,6 +529,7 @@ const dialog = useDialog()
|
||||
const configs = ref<DDNSConfig[]>([])
|
||||
const loading = ref(false)
|
||||
const frpEnabled = ref(false)
|
||||
const bypassFrpHint = ref(false)
|
||||
const refreshingIP = ref(false)
|
||||
const loadError = ref('')
|
||||
const savingTask = ref(false)
|
||||
|
||||
@@ -912,6 +912,7 @@ import {
|
||||
WarningOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { api, asList } from '../api/client'
|
||||
import { withBase } from '../api/base'
|
||||
import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
|
||||
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
@@ -1835,7 +1836,7 @@ function stopLogStream() {
|
||||
function startLogStream() {
|
||||
const rule = selectedRule.value
|
||||
if (!rule || logEventSource) return
|
||||
logEventSource = new EventSource(`/api/proxies/${rule.id}/logs/stream?tail=100`, {
|
||||
logEventSource = new EventSource(withBase(`/api/proxies/${rule.id}/logs/stream?tail=100`), {
|
||||
withCredentials: true,
|
||||
})
|
||||
logEventSource.addEventListener('log', (event) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import vue from '@vitejs/plugin-vue'
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string }
|
||||
|
||||
export default defineConfig({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
|
||||
@@ -15,6 +16,8 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:6893',
|
||||
timeout: 300_000,
|
||||
proxyTimeout: 300_000,
|
||||
configure(proxy) {
|
||||
proxy.on('proxyRes', (proxyRes, req) => {
|
||||
if (req.url?.includes('/stream')) {
|
||||
|
||||
Reference in New Issue
Block a user