feat(extensions): 使用 EasyFrp 替换 gostc

将扩展配置、接口和设置页切换为 EasyFrp,支持按行填写多个客户端连接 URL,通过 easyfrpc control 启动并校验协议与密钥路径。

旧 gostc 配置不自动启用 EasyFrp,升级后需重新配置;启动日志不再输出命令参数,避免暴露连接信息。

完整 Docker 镜像包含 easyfrpc、easytier-core、easytier-cli 和 frpc,通过独立下载脚本及版本锁定文件校验下载摘要和二进制架构。
This commit is contained in:
mofeng-git
2026-10-18 18:09:59 +08:00
parent c4344e4a1c
commit 3f970ee834
17 changed files with 369 additions and 199 deletions

View File

@@ -26,8 +26,8 @@ import type {
ExtensionLogs,
TtydConfig,
TtydConfigUpdate,
GostcConfig,
GostcConfigUpdate,
EasyfrpConfig,
EasyfrpConfigUpdate,
EasytierConfig,
EasytierConfigUpdate,
FrpcConfig,
@@ -201,8 +201,8 @@ export const extensionsApi = {
body: JSON.stringify(config),
}),
updateGostc: (config: GostcConfigUpdate) =>
request<GostcConfig>('/extensions/gostc/config', {
updateEasyfrp: (config: EasyfrpConfigUpdate) =>
request<EasyfrpConfig>('/extensions/easyfrp/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),

View File

@@ -1136,15 +1136,14 @@ export default {
openInNewTab: 'Open in New Tab',
shell: 'Shell',
},
gostc: {
title: 'GOSTC NAT Traversal',
desc: 'NAT traversal via GOSTC',
addr: 'Server Address',
addrPlaceholder: 'Hostname or IP (required)',
addrRequired: 'Enter the GOSTC server address',
key: 'Client Key',
keyRequired: 'Enter the GOSTC client key',
tls: 'Enable TLS',
easyfrp: {
title: 'EasyFrp NAT Traversal',
desc: 'Connect to a tunnel service with the EasyFrp client',
urls: 'Client Connection URLs',
urlsPlaceholder: 'wss://example.com/client-key',
urlsHint: 'One full URL per line. Supports ws, wss, http, https and txt. Copy client URLs from the EasyFrp dashboard.',
urlsRequired: 'Enter an EasyFrp client connection URL',
urlsInvalid: 'Enter valid ws/wss/http/https/txt URLs with a client key path',
},
easytier: {
title: 'Easytier Network',

View File

@@ -1135,15 +1135,14 @@ export default {
openInNewTab: '在新标签页打开',
shell: 'Shell',
},
gostc: {
title: 'GOSTC 内网穿透',
desc: '通过 GOSTC 实现内网穿透',
addr: '服务器地址',
addrPlaceholder: '主机名或 IP(必填)',
addrRequired: '请填写 GOSTC 服务器地址',
key: '客户端密钥',
keyRequired: '请填写 GOSTC 客户端密钥',
tls: '启用 TLS',
easyfrp: {
title: 'EasyFrp 内网穿透',
desc: '使用 EasyFrp 客户端连接穿透服务',
urls: '客户端连接 URL',
urlsPlaceholder: 'wss://example.com/client-key',
urlsHint: '每行一个完整连接 URL,支持 ws、wss、http、https、txt。请从 EasyFrp 管理端复制客户端地址。',
urlsRequired: '请填写 EasyFrp 客户端连接 URL',
urlsInvalid: '请填写带客户端密钥路径的有效 ws/wss/http/https/txt URL',
},
easytier: {
title: 'Easytier 组网',

View File

@@ -196,11 +196,9 @@ export interface TtydConfig {
shell: string;
}
export interface GostcConfig {
export interface EasyfrpConfig {
enabled: boolean;
addr: string;
key: string;
tls: boolean;
control_urls: string[];
}
export enum EasytierConfigMode {
@@ -252,7 +250,7 @@ export interface FrpcConfig {
export interface ExtensionsConfig {
ttyd: TtydConfig;
gostc: GostcConfig;
easyfrp: EasyfrpConfig;
easytier: EasytierConfig;
frpc: FrpcConfig;
}
@@ -489,7 +487,7 @@ export interface ExtensionInfo {
export enum ExtensionId {
Ttyd = "ttyd",
Gostc = "gostc",
Easyfrp = "easyfrp",
Easytier = "easytier",
Frpc = "frpc",
}
@@ -505,10 +503,10 @@ export interface TtydInfo {
config: TtydConfig;
}
export interface GostcInfo {
export interface EasyfrpInfo {
available: boolean;
status: ExtensionStatus;
config: GostcConfig;
config: EasyfrpConfig;
}
export interface FrpcInfo {
@@ -519,7 +517,7 @@ export interface FrpcInfo {
export interface ExtensionsStatus {
ttyd: TtydInfo;
gostc: GostcInfo;
easyfrp: EasyfrpInfo;
easytier: EasytierInfo;
frpc: FrpcInfo;
}
@@ -541,11 +539,9 @@ export interface FrpcConfigUpdate {
custom_toml?: string;
}
export interface GostcConfigUpdate {
export interface EasyfrpConfigUpdate {
enabled?: boolean;
addr?: string;
key?: string;
tls?: boolean;
control_urls?: string[];
}
/** OTG USB device descriptor configuration update */

View File

@@ -434,13 +434,13 @@ const extensions = ref<ExtensionsStatus | null>(null)
const extensionsLoading = ref(false)
const extensionLogs = ref<Record<string, string[]>>({
ttyd: [],
gostc: [],
easyfrp: [],
easytier: [],
frpc: [],
})
const showLogs = ref<Record<string, boolean>>({
ttyd: false,
gostc: false,
easyfrp: false,
easytier: false,
frpc: false,
})
@@ -449,7 +449,7 @@ const showTerminalDialog = ref(false)
const extConfig = ref({
ttyd: { enabled: false, shell: '/bin/bash' },
gostc: { enabled: false, addr: '', key: '', tls: true },
easyfrp: { enabled: false, control_urls: [] as string[] },
easytier: {
enabled: false,
config_mode: EasytierConfigMode.Quick,
@@ -477,9 +477,27 @@ const extConfig = ref({
},
})
const gostcValidationMessage = computed(() => {
if (!extConfig.value.gostc.addr?.trim()) return t('extensions.gostc.addrRequired')
if (!extConfig.value.gostc.key) return t('extensions.gostc.keyRequired')
const easyfrpUrlsText = computed({
get: () => extConfig.value.easyfrp.control_urls.join('\n'),
set: (value: string) => {
extConfig.value.easyfrp.control_urls = value.split(/\r?\n/).map(url => url.trim()).filter(Boolean)
},
})
const easyfrpValidationMessage = computed(() => {
const urls = extConfig.value.easyfrp.control_urls
if (urls.length === 0) return t('extensions.easyfrp.urlsRequired')
for (const value of urls) {
try {
const url = new URL(value)
if (/\s/.test(value) || !['ws:', 'wss:', 'http:', 'https:', 'txt:'].includes(url.protocol)
|| !url.hostname || !url.pathname.replace(/\//g, '') || url.username || url.password || url.hash) {
return t('extensions.easyfrp.urlsInvalid')
}
} catch {
return t('extensions.easyfrp.urlsInvalid')
}
}
return ''
})
@@ -1698,7 +1716,7 @@ async function loadExtensions() {
enabled: ttyd.enabled,
shell: ttyd.shell,
}
extConfig.value.gostc = { ...extensions.value.gostc.config }
extConfig.value.easyfrp = { ...extensions.value.easyfrp.config }
const easytier = extensions.value.easytier.config
extConfig.value.easytier = {
enabled: easytier.enabled,
@@ -1733,7 +1751,7 @@ async function loadExtensions() {
}
}
type ExtensionConfigId = 'ttyd' | 'gostc' | 'easytier' | 'frpc'
type ExtensionConfigId = 'ttyd' | 'easyfrp' | 'easytier' | 'frpc'
type ValidatedExtensionConfigId = Exclude<ExtensionConfigId, 'ttyd'>
async function startExtension(id: ExtensionConfigId) {
@@ -1774,8 +1792,8 @@ async function saveExtensionConfig(id: ExtensionConfigId) {
try {
if (id === 'ttyd') {
await extensionsApi.updateTtyd(extConfig.value.ttyd)
} else if (id === 'gostc') {
await extensionsApi.updateGostc(extConfig.value.gostc)
} else if (id === 'easyfrp') {
await extensionsApi.updateEasyfrp(extConfig.value.easyfrp)
} else if (id === 'easytier') {
await extensionsApi.updateEasytier(extConfig.value.easytier)
} else if (id === 'frpc') {
@@ -2052,8 +2070,8 @@ function showValidationError(message: string): boolean {
function validateExtensionConfig(id: ValidatedExtensionConfigId): boolean {
let message = ''
if (id === 'gostc') {
message = gostcValidationMessage.value
if (id === 'easyfrp') {
message = easyfrpValidationMessage.value
} else if (id === 'easytier') {
message = easytierValidationMessage.value
} else {
@@ -4430,31 +4448,31 @@ watch(isWindows, () => {
<CardHeader>
<div class="flex items-center justify-between">
<div class="space-y-1.5">
<CardTitle>{{ t('extensions.gostc.title') }}</CardTitle>
<CardDescription>{{ t('extensions.gostc.desc') }}</CardDescription>
<CardTitle>{{ t('extensions.easyfrp.title') }}</CardTitle>
<CardDescription>{{ t('extensions.easyfrp.desc') }}</CardDescription>
</div>
<Badge :variant="extensions?.gostc?.available ? 'default' : 'destructive'">
{{ extensions?.gostc?.available ? t('extensions.available') : t('extensions.unavailable') }}
<Badge :variant="extensions?.easyfrp?.available ? 'default' : 'destructive'">
{{ extensions?.easyfrp?.available ? t('extensions.available') : t('extensions.unavailable') }}
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div v-if="!extensions?.gostc?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
{{ t('extensions.binaryNotFound', { path: isWindows ? 'gostc.exe' : '/usr/bin/gostc' }) }}
<div v-if="!extensions?.easyfrp?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
{{ t('extensions.binaryNotFound', { path: isWindows ? 'easyfrpc.exe' : '/usr/bin/easyfrpc' }) }}
</div>
<template v-else>
<!-- Status and controls -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div :class="['size-2 rounded-full', getExtStatusClass(extensions?.gostc?.status)]" />
<span class="text-sm">{{ getExtStatusText(extensions?.gostc?.status) }}</span>
<div :class="['size-2 rounded-full', getExtStatusClass(extensions?.easyfrp?.status)]" />
<span class="text-sm">{{ getExtStatusText(extensions?.easyfrp?.status) }}</span>
</div>
<div class="flex gap-2">
<Button
v-if="!isExtRunning(extensions?.gostc?.status)"
v-if="!isExtRunning(extensions?.easyfrp?.status)"
size="sm"
@click="startExtension('gostc')"
:disabled="extensionsLoading || !!gostcValidationMessage"
@click="startExtension('easyfrp')"
:disabled="extensionsLoading || !!easyfrpValidationMessage"
>
<Play class="size-4 mr-1" />
{{ t('extensions.start') }}
@@ -4463,7 +4481,7 @@ watch(isWindows, () => {
v-else
size="sm"
variant="outline"
@click="stopExtension('gostc')"
@click="stopExtension('easyfrp')"
:disabled="extensionsLoading"
>
<Square class="size-4 mr-1" />
@@ -4476,36 +4494,24 @@ watch(isWindows, () => {
<div class="grid gap-4">
<div class="flex items-center justify-between">
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="extConfig.gostc.enabled" :disabled="isExtRunning(extensions?.gostc?.status)" />
<Switch v-model="extConfig.easyfrp.enabled" :disabled="isExtRunning(extensions?.easyfrp?.status)" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
<Label class="sm:text-right">{{ t('extensions.easyfrp.urls') }}</Label>
<div class="sm:col-span-3 space-y-1">
<Input v-model="extConfig.gostc.addr" :placeholder="t('extensions.gostc.addrPlaceholder')" :disabled="isExtRunning(extensions?.gostc?.status)" />
<p v-if="extConfig.gostc.enabled && !extConfig.gostc.addr?.trim()" class="text-xs text-destructive">{{ t('extensions.gostc.addrRequired') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
<div class="sm:col-span-3 space-y-1">
<Input v-model="extConfig.gostc.key" type="password" autocomplete="off" :disabled="isExtRunning(extensions?.gostc?.status)" />
<p v-if="extConfig.gostc.enabled && !extConfig.gostc.key" class="text-xs text-destructive">{{ t('extensions.gostc.keyRequired') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.tls') }}</Label>
<div class="sm:col-span-3">
<Switch v-model="extConfig.gostc.tls" :disabled="isExtRunning(extensions?.gostc?.status)" />
<Textarea v-model="easyfrpUrlsText" :rows="3" autocomplete="off" :spellcheck="false" :placeholder="t('extensions.easyfrp.urlsPlaceholder')" :disabled="isExtRunning(extensions?.easyfrp?.status)" />
<p class="text-xs text-muted-foreground">{{ t('extensions.easyfrp.urlsHint') }}</p>
<p v-if="extConfig.easyfrp.enabled && easyfrpValidationMessage" class="text-xs text-destructive">{{ easyfrpValidationMessage }}</p>
</div>
</div>
</div>
<!-- Logs -->
<div class="space-y-2">
<Collapsible v-model:open="showLogs.gostc" @update:open="open => open && refreshExtensionLogs('gostc')">
<CollapsibleTrigger as-child><Button type="button" variant="ghost" size="sm" class="gap-2 text-muted-foreground"><ChevronRight :class="['size-4 transition-transform', showLogs.gostc ? 'rotate-90' : '']" />{{ t('extensions.viewLogs') }}</Button></CollapsibleTrigger>
<Collapsible v-model:open="showLogs.easyfrp" @update:open="open => open && refreshExtensionLogs('easyfrp')">
<CollapsibleTrigger as-child><Button type="button" variant="ghost" size="sm" class="gap-2 text-muted-foreground"><ChevronRight :class="['size-4 transition-transform', showLogs.easyfrp ? 'rotate-90' : '']" />{{ t('extensions.viewLogs') }}</Button></CollapsibleTrigger>
<CollapsibleContent class="space-y-2 pt-2">
<pre class="p-3 bg-muted rounded-md text-xs max-h-48 overflow-auto font-mono">{{ (extensionLogs.gostc || []).join('\n') || t('extensions.noLogs') }}</pre>
<Button variant="ghost" size="sm" @click="refreshExtensionLogs('gostc')">
<pre class="p-3 bg-muted rounded-md text-xs max-h-48 overflow-auto font-mono">{{ (extensionLogs.easyfrp || []).join('\n') || t('extensions.noLogs') }}</pre>
<Button variant="ghost" size="sm" @click="refreshExtensionLogs('easyfrp')">
<RefreshCw class="size-3 mr-1" />
{{ t('common.refresh') }}
</Button>
@@ -4514,8 +4520,8 @@ watch(isWindows, () => {
</div>
</template>
</CardContent>
<CardFooter v-if="extensions?.gostc?.available" class="border-t pt-4 justify-end">
<Button :disabled="loading || isExtRunning(extensions?.gostc?.status)" @click="saveExtensionConfig('gostc')">
<CardFooter v-if="extensions?.easyfrp?.available" class="border-t pt-4 justify-end">
<Button :disabled="loading || isExtRunning(extensions?.easyfrp?.status)" @click="saveExtensionConfig('easyfrp')">
<Loader2 v-if="loading" class="size-4 mr-2 animate-spin" /><Check v-else-if="saved" class="size-4 mr-2" /><Save v-else class="size-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
</Button>
</CardFooter>