chore: 初始提交 Fonu 1.0.2

- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听)
- 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP)
- 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点)
- 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
import type { AccessLogEntry, ProxyHost, ProxyRule } from '../api/types'
export type ProxyBinding = {
name: string
upstream: string
host: string
port: number
}
function bindingKey(host: string, port: number): string {
return `${host.toLowerCase()}:${port}`
}
function normalizeAccessHost(domain: string): string {
const raw = domain.trim().toLowerCase()
if (!raw) return ''
if (raw.startsWith('[')) {
const end = raw.indexOf(']')
if (end > 0) return raw.slice(0, end + 1)
}
if (raw.includes(':') && !raw.includes(']')) {
const idx = raw.lastIndexOf(':')
const maybePort = Number.parseInt(raw.slice(idx + 1), 10)
if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
return raw.slice(0, idx)
}
}
return raw
}
export function buildProxyBindingIndex(rules: ProxyRule[]): Map<string, ProxyBinding> {
const index = new Map<string, ProxyBinding>()
for (const rule of rules) {
const fallbackName = rule.domain?.trim() || rule.upstream?.trim() || `规则 #${rule.id}`
const name = rule.name?.trim() || fallbackName
const hosts: ProxyHost[] = rule.hosts?.length
? rule.hosts
: rule.domain
? [{ id: 0, hostname: rule.domain }]
: []
for (const host of hosts) {
const hostname = host.hostname?.trim()
if (!hostname) continue
const port = host.listen_port ?? rule.listen_port
if (port <= 0) continue
index.set(bindingKey(hostname, port), {
name,
upstream: rule.upstream,
host: hostname.toLowerCase(),
port,
})
}
}
return index
}
function formatUpstreamShort(upstream: string): string {
return upstream.replace(/^https?:\/\//i, '')
}
/** Raw host:port / domain / IP shown when no proxy rule matches. */
export function formatAccessFallback(entry: AccessLogEntry): string {
const rawDomain = entry.domain?.trim()
const host = normalizeAccessHost(entry.domain)
const port = entry.server_port ?? 0
if (rawDomain?.includes(':') && (rawDomain.includes(']') || rawDomain.lastIndexOf(':') > rawDomain.lastIndexOf(']'))) {
return rawDomain
}
if (rawDomain?.includes(':') && !rawDomain.includes(']')) {
const idx = rawDomain.lastIndexOf(':')
const maybePort = Number.parseInt(rawDomain.slice(idx + 1), 10)
if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
return rawDomain
}
}
if (host && port > 0) return `${host}:${port}`
if (rawDomain) return rawDomain
if (host) return host
return entry.client_ip?.trim() || '—'
}
export function resolveAccessServiceLabel(
entry: AccessLogEntry,
index: Map<string, ProxyBinding>,
): string {
const host = normalizeAccessHost(entry.domain)
const port = entry.server_port ?? 0
// 域名 + 端口(同域多端口)
if (host && port > 0) {
const hit = index.get(bindingKey(host, port))
if (hit) return hit.name
}
// 仅域名(单规则或同域单端口)
if (host) {
const matches = [...index.values()].filter((binding) => binding.host === host)
if (matches.length === 1) return matches[0].name
if (matches.length > 1 && port > 0) {
const byPort = matches.find((binding) => binding.port === port)
if (byPort) return byPort.name
}
}
// 仅 IP / 域名直连、无 Host 匹配时走 upstream
const upstream = entry.upstream?.trim()
if (upstream && upstream !== '-') {
return formatUpstreamShort(upstream)
}
return formatAccessFallback(entry)
}
export function accessServiceTooltip(entry: AccessLogEntry, index: Map<string, ProxyBinding>): string {
const host = normalizeAccessHost(entry.domain)
const port = entry.server_port ?? 0
const label = resolveAccessServiceLabel(entry, index)
const parts: string[] = [label]
const requestHost = formatAccessFallback(entry)
if (requestHost !== label) {
parts.push(`请求 ${requestHost}`)
} else if (host) {
parts.push(port > 0 ? `${host}:${port}` : host)
}
const binding = host && port > 0 ? index.get(bindingKey(host, port)) : undefined
if (binding?.upstream) {
parts.push(`→ ${binding.upstream}`)
} else if (entry.upstream && entry.upstream !== '-') {
parts.push(`→ ${entry.upstream}`)
}
if (entry.client_ip) parts.push(`来自 ${entry.client_ip}`)
if (entry.path) parts.push(entry.path)
return parts.join(' · ')
}
+103
View File
@@ -0,0 +1,103 @@
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2
return `${value.toFixed(digits)} ${units[unit]}`
}
export function formatRate(bytesPerSec: number): string {
if (!Number.isFinite(bytesPerSec) || bytesPerSec <= 0) return '0 B/s'
return `${formatBytes(bytesPerSec)}/s`
}
export function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return `${h} 小时 ${m} 分钟`
return `${m} 分钟`
}
export function formatRelativeTime(iso?: string): string {
if (!iso) return '-'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
const diff = Date.now() - date.getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
return `${days} 天前`
}
export function formatMs(seconds: number): string {
const ms = Math.round(seconds * 1000)
return `${ms} ms`
}
export function formatDate(iso?: string): string {
if (!iso) return '-'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
/** Normalize log timestamps to YYYY-MM-DD HH:mm:ss */
export function formatLogTime(value?: string): string {
if (!value) return '-'
const trimmed = value.trim()
const standard = trimmed.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/)
if (standard) return standard[1]
const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/)
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]} ${iso[4]}:${iso[5]}:${iso[6]}`
const nginx = trimmed.match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
if (nginx) return `${nginx[1]}-${nginx[2]}-${nginx[3]} ${nginx[4]}:${nginx[5]}:${nginx[6]}`
const date = new Date(trimmed)
if (!Number.isNaN(date.getTime())) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
return trimmed
}
/** Normalize leading timestamp in a raw log line */
export function formatLogLine(line: string): string {
const trimmed = line.trim()
if (!trimmed) return line
const nginx = trimmed.match(/^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2})(.*)$/)
if (nginx) return formatLogTime(nginx[1]) + nginx[2]
const iso = trimmed.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}|Z)?)(.*)$/)
if (iso) return formatLogTime(iso[1]) + iso[2]
if (trimmed.startsWith('{')) {
try {
const raw = JSON.parse(trimmed) as { time?: string }
if (raw.time) return trimmed.replace(raw.time, formatLogTime(raw.time))
} catch {
// not JSON
}
}
return trimmed
}
+165
View File
@@ -0,0 +1,165 @@
import type { SystemLogEntry } from '../api/types'
import { formatLogTime } from './format'
export type LogTagType = 'success' | 'info' | 'warning' | 'error' | 'default'
export interface DisplaySystemLogEntry extends SystemLogEntry {
displayLevel: string
displayModule: string
}
const SYSTEM_LEVEL_LABELS: Record<string, string> = {
INFO: '信息',
WARN: '警告',
ERROR: '错误',
DEBUG: '调试',
}
const SYSTEM_MODULE_LABELS: Record<string, string> = {
SYSTEM: '系统',
NGINX: 'Nginx',
DDNS: '域名解析',
ACME: '证书',
CERT: '证书',
CHINA_CIDR: '中国 IP 段',
API: 'API',
HTTP: 'HTTP',
FRP: '内网穿透',
}
export function systemLevelLabel(level: string): string {
const upper = level.trim().toUpperCase()
return SYSTEM_LEVEL_LABELS[upper] ?? level
}
export function systemModuleLabel(module: string): string {
const trimmed = module.trim()
if (!trimmed) return '系统'
const upper = trimmed.toUpperCase()
return SYSTEM_MODULE_LABELS[upper] ?? trimmed
}
export function displaySystemLog(entry: SystemLogEntry): DisplaySystemLogEntry {
const message = entry.message.trim()
const msgLower = message.toLowerCase()
let level = entry.level.trim().toUpperCase()
let displayModule = systemModuleLabel(entry.module)
if (
level === 'INFO' &&
(msgLower.includes('server error') ||
msgLower.includes(' failed') ||
msgLower.startsWith('error') ||
msgLower.includes('bind:'))
) {
level = 'ERROR'
}
return {
...entry,
displayLevel: systemLevelLabel(level),
displayModule,
}
}
export interface ParsedNginxError {
time: string
level: string
message: string
raw: string
}
export function parseNginxErrorLine(line: string): ParsedNginxError {
const raw = line.trim()
const match = raw.match(/^(\d{4}[-/]\d{2}[-/]\d{2} \d{2}:\d{2}:\d{2})\s+\[([^\]]+)\]\s+(.+)$/)
if (!match) {
return { time: '', level: 'unknown', message: raw, raw }
}
return {
time: formatLogTime(match[1]),
level: match[2].toLowerCase(),
message: match[3],
raw,
}
}
export function nginxLevelTagType(level: string): LogTagType {
switch (level.toLowerCase()) {
case 'emerg':
case 'alert':
case 'crit':
case 'error':
return 'error'
case 'warn':
case 'warning':
return 'warning'
case 'notice':
case 'info':
return 'info'
default:
return 'default'
}
}
export function systemLevelTagType(level: string): LogTagType {
const upper = level.trim().toUpperCase()
if (upper === 'ERROR' || level === '错误') return 'error'
if (upper === 'WARN' || level === '警告') return 'warning'
if (upper === 'INFO' || level === '信息') return 'info'
return 'default'
}
export function systemModuleTagType(module: string): LogTagType {
const upper = module.trim().toUpperCase()
switch (upper) {
case 'NGINX':
case 'Nginx':
return 'warning'
case 'SYSTEM':
case '系统':
return 'success'
case 'CHINA_CIDR':
case '中国 IP 段':
return 'info'
case 'DDNS':
case '域名解析':
return 'info'
case 'CERT':
case 'ACME':
case '证书':
return 'warning'
default:
return 'default'
}
}
export function latencyClass(seconds: number): string {
const ms = seconds * 1000
if (ms >= 3000) return 'log-duration log-duration--critical'
if (ms >= 1000) return 'log-duration log-duration--slow'
return 'log-duration'
}
export function streamLineClass(line: string): string {
const parsed = parseNginxErrorLine(line)
if (parsed.level !== 'unknown') {
const type = nginxLevelTagType(parsed.level)
if (type === 'error') return 'level-error'
if (type === 'warning') return 'level-warn'
if (type === 'info') return 'level-info'
}
const lower = line.toLowerCase()
if (lower.includes('[error]') || lower.includes(' emerg ') || lower.includes(' alert ') || lower.includes(' crit ')) {
return 'level-error'
}
if (lower.includes('[warn]') || lower.includes(' warning ')) return 'level-warn'
const statusMatch = line.match(/\s(\d{3})\s+\d/)
if (statusMatch) {
const code = Number(statusMatch[1])
if (code >= 500) return 'level-error'
if (code >= 400) return 'level-warn'
}
return ''
}
+42
View File
@@ -0,0 +1,42 @@
export type StatusKind = 'success' | 'warning' | 'error' | 'disabled' | 'unknown'
const LABELS: Record<string, string> = {
ok: '正常',
running: '运行中',
warning: '部分异常',
error: '异常',
disabled: '停用',
none: '未配置',
stopped: '已停止',
unknown: '未知',
}
export function statusLabel(value?: string): string {
if (!value) return '未知'
return LABELS[value] ?? value
}
export function statusKind(value?: string): StatusKind {
switch (value) {
case 'ok':
case 'running':
return 'success'
case 'warning':
return 'warning'
case 'error':
return 'error'
case 'disabled':
case 'stopped':
case 'none':
return 'disabled'
default:
return 'unknown'
}
}
export function httpStatusKind(code: number): StatusKind {
if (code >= 500) return 'error'
if (code >= 400) return 'warning'
if (code >= 300) return 'unknown'
return 'success'
}
+34
View File
@@ -0,0 +1,34 @@
import { h, type VNode } from 'vue'
import { NButton, NSpace } from 'naive-ui'
import type { ButtonProps } from 'naive-ui'
export interface TableRowAction {
label: string
onClick: () => void
type?: ButtonProps['type']
/** 为 false 时隐藏该操作,默认显示 */
show?: boolean
}
/** 表格操作列:所有按钮平铺展示,不使用下拉菜单 */
export function renderTableRowActions(actions: TableRowAction[]): VNode {
const buttons = actions
.filter((action) => action.show !== false)
.map((action) =>
h(
NButton,
{
size: 'small',
quaternary: true,
type: action.type,
onClick: (e: MouseEvent) => {
e.stopPropagation()
action.onClick()
},
},
() => action.label,
),
)
return h(NSpace, { size: 4, wrap: false }, () => buttons)
}
+56
View File
@@ -0,0 +1,56 @@
import { GITHUB_REPO } from '../constants/app'
export interface LatestRelease {
version: string
url: string
}
function parseVersion(version: string): number[] {
const clean = version.replace(/^v/i, '').split('-')[0]
return clean.split('.').map((part) => Number.parseInt(part, 10) || 0)
}
export function compareVersions(a: string, b: string): number {
const left = parseVersion(a)
const right = parseVersion(b)
const length = Math.max(left.length, right.length)
for (let i = 0; i < length; i += 1) {
const diff = (left[i] ?? 0) - (right[i] ?? 0)
if (diff !== 0) {
return diff > 0 ? 1 : -1
}
}
return 0
}
export function isNewerVersion(latest: string, current: string): boolean {
if (!latest || !current || current === 'dev') {
return false
}
return compareVersions(latest, current) > 0
}
export async function fetchLatestRelease(): Promise<LatestRelease | null> {
try {
const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
})
if (!response.ok) {
return null
}
const data = (await response.json()) as { tag_name?: string; html_url?: string }
if (!data.tag_name || !data.html_url) {
return null
}
return {
version: data.tag_name.replace(/^v/i, ''),
url: data.html_url,
}
} catch {
return null
}
}