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

- 「公网服务端」新增 Nginx 启停控制(Agent 0.15.2,停止带二次确认)
- frps 启动失败不再误报成功,并增强启动诊断
- frps vhost 端口禁止 80/443,避免与 VPS 上 Nginx 冲突
- 旧服务端配置的 tcp_mux / login_fail_exit 默认值补齐
- 新增安全审查、页面布局方案与场景操作文档
This commit is contained in:
2026-10-07 19:26:02 +08:00
parent a7d71752e3
commit 2a0740a60f
55 changed files with 5065 additions and 467 deletions
+1 -1
View File
@@ -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": {
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
-93
View File
@@ -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>
+3 -1
View File
@@ -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`
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 = {
+3 -1
View File
@@ -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)
+2 -1
View File
@@ -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) => {
+3
View File
@@ -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')) {