diff --git a/build/Dockerfile.runtime-full b/build/Dockerfile.runtime-full index 3c320a03..f869b90f 100644 --- a/build/Dockerfile.runtime-full +++ b/build/Dockerfile.runtime-full @@ -52,7 +52,7 @@ RUN if [ -n "$APT_MIRROR" ]; then \ COPY --chmod=755 init.sh /init.sh # Copy binaries (these are placed by the build script) -COPY --chmod=755 one-kvm ttyd gostc easytier-core /usr/bin/ +COPY --chmod=755 one-kvm ttyd easyfrpc easytier-core easytier-cli frpc /usr/bin/ # Copy ventoy resources if they exist COPY ventoy/ /etc/one-kvm/ventoy/ diff --git a/build/download-remote-tools.py b/build/download-remote-tools.py new file mode 100644 index 00000000..78f494fd --- /dev/null +++ b/build/download-remote-tools.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Fetch the pinned EasyFrp, EasyTier and frp clients for full Docker images.""" +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import struct +import tarfile +import tempfile +import urllib.request +import zipfile + + +def install(platform, output): + architecture = {'linux/amd64': 'x86_64', 'linux/arm64': 'aarch64', + 'linux/arm/v7': 'arm'}[platform] + lock = json.loads(Path(__file__).with_name('remote-access.lock.json').read_text()) + output.mkdir(parents=True, exist_ok=True) + expected = {'arm': (1, 40), 'aarch64': (2, 183), 'x86_64': (2, 62)}[architecture] + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({'http': os.environ['CURL_PROXY'], 'https': os.environ['CURL_PROXY']}) + ) if os.environ.get('CURL_PROXY') else urllib.request.build_opener() + # Validate the entire download before replacing any staged executable. + with tempfile.TemporaryDirectory(prefix='one-kvm-remote-tools-') as temporary: + staging = Path(temporary) + for spec in lock['tools'].values(): + asset = spec['assets'][architecture] + archive = staging / asset['archive'] + prefix = os.environ.get('GHPROXY_PREFIX', '').rstrip('/') + url = prefix + '/' + asset['url'] if prefix else asset['url'] + with opener.open(url, timeout=120) as response: + with archive.open('wb') as stream: + shutil.copyfileobj(response, stream) + if hashlib.sha256(archive.read_bytes()).hexdigest() != asset['sha256']: + raise ValueError('release checksum differs: ' + archive.name) + for name in spec['binaries']: + if zipfile.is_zipfile(archive): + with zipfile.ZipFile(archive) as source: + matches = [item for item in source.infolist() + if not item.is_dir() and PurePosixPath(item.filename).name == name] + if len(matches) != 1: + raise ValueError('expected one executable: ' + name) + data = source.read(matches[0]) + else: + with tarfile.open(archive, 'r:gz') as source: + matches = [item for item in source if item.isfile() and PurePosixPath(item.name).name == name] + if len(matches) != 1: + raise ValueError('expected one executable: ' + name) + data = source.extractfile(matches[0]).read() + if (data[:4] != b'\x7fELF' or data[5] != 1 + or (data[4], struct.unpack(' bool { match id { ExtensionId::Ttyd => config.ttyd.enabled, - ExtensionId::Gostc => { - config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok() + ExtensionId::Easyfrp => { + config.easyfrp.enabled && validate_easyfrp_config(&config.easyfrp).is_ok() } ExtensionId::Easytier => { config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok() @@ -144,12 +144,7 @@ impl ExtensionManager { let launch = self.build_launch(id, config).await?; - tracing::info!( - "Starting extension {}: {} {}", - id, - id.binary_path().display(), - launch.args.join(" ") - ); + tracing::info!("Starting extension {}: {}", id, id.binary_path().display()); let mut child = Command::new(id.binary_path()) .args(&launch.args) @@ -272,17 +267,12 @@ impl ExtensionManager { args } - ExtensionId::Gostc => { - let c = &config.gostc; - validate_gostc_config(c)?; - - // gostc defaults to TLS, so disabling it must be explicit. - let mut args = vec![format!("--tls={}", c.tls)]; - - args.extend(["-addr".to_string(), c.addr.trim().to_string()]); - - args.extend(["-key".to_string(), c.key.clone()]); + ExtensionId::Easyfrp => { + let c = &config.easyfrp; + validate_easyfrp_config(c)?; + let mut args = vec!["control".to_string()]; + args.extend(c.control_urls.iter().map(|url| url.trim().to_string())); args } @@ -580,6 +570,27 @@ impl ExtensionManager { mod tests { use super::*; + #[tokio::test] + async fn easyfrp_launch_uses_control_urls_as_separate_arguments() { + let mut config = ExtensionsConfig::default(); + config.easyfrp.control_urls = vec![ + "wss://example.com/key-one".to_string(), + "txt://example.net/key-two".to_string(), + ]; + let launch = ExtensionManager::new() + .build_launch(ExtensionId::Easyfrp, &config) + .await + .unwrap(); + assert_eq!( + launch.args, + [ + "control", + "wss://example.com/key-one", + "txt://example.net/key-two" + ] + ); + } + #[tokio::test] async fn easytier_launch_revalidates_full_configuration() { let config = EasytierConfig { diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 19ce6845..3fcb695d 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -11,6 +11,6 @@ pub use manager::TTYD_SOCKET_PATH; pub use manager::TTYD_TCP_ADDR; pub use types::*; pub(crate) use validation::{ - validate_easytier_config, validate_extension_config, validate_frpc_config, - validate_gostc_config, + validate_easyfrp_config, validate_easytier_config, validate_extension_config, + validate_frpc_config, }; diff --git a/src/extensions/software_linux.rs b/src/extensions/software_linux.rs index ec4e7926..db924db8 100644 --- a/src/extensions/software_linux.rs +++ b/src/extensions/software_linux.rs @@ -5,7 +5,7 @@ use super::ExtensionId; pub fn default_binary_path(id: ExtensionId) -> &'static str { match id { ExtensionId::Ttyd => "/usr/bin/ttyd", - ExtensionId::Gostc => "/usr/bin/gostc", + ExtensionId::Easyfrp => "/usr/bin/easyfrpc", ExtensionId::Easytier => "/usr/bin/easytier-core", ExtensionId::Frpc => "/usr/bin/frpc", } diff --git a/src/extensions/software_windows.rs b/src/extensions/software_windows.rs index 4e2f45a4..6e4db753 100644 --- a/src/extensions/software_windows.rs +++ b/src/extensions/software_windows.rs @@ -5,7 +5,7 @@ use super::ExtensionId; pub fn default_binary_path(id: ExtensionId) -> &'static str { match id { ExtensionId::Ttyd => "ttyd.win32.exe", - ExtensionId::Gostc => "gostc.exe", + ExtensionId::Easyfrp => "easyfrpc.exe", ExtensionId::Easytier => "easytier-core.exe", ExtensionId::Frpc => "frpc.exe", } diff --git a/src/extensions/types.rs b/src/extensions/types.rs index 45839d0f..08e90f27 100644 --- a/src/extensions/types.rs +++ b/src/extensions/types.rs @@ -8,7 +8,7 @@ use super::software; #[serde(rename_all = "lowercase")] pub enum ExtensionId { Ttyd, - Gostc, + Easyfrp, Easytier, Frpc, } @@ -19,7 +19,7 @@ impl ExtensionId { } pub fn all() -> &'static [ExtensionId] { - &[Self::Ttyd, Self::Gostc, Self::Easytier, Self::Frpc] + &[Self::Ttyd, Self::Easyfrp, Self::Easytier, Self::Frpc] } } @@ -27,7 +27,7 @@ impl std::fmt::Display for ExtensionId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Ttyd => write!(f, "ttyd"), - Self::Gostc => write!(f, "gostc"), + Self::Easyfrp => write!(f, "easyfrp"), Self::Easytier => write!(f, "easytier"), Self::Frpc => write!(f, "frpc"), } @@ -40,7 +40,7 @@ impl std::str::FromStr for ExtensionId { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "ttyd" => Ok(Self::Ttyd), - "gostc" => Ok(Self::Gostc), + "easyfrp" => Ok(Self::Easyfrp), "easytier" => Ok(Self::Easytier), "frpc" => Ok(Self::Frpc), _ => Err(format!("Unknown extension: {}", s)), @@ -81,25 +81,11 @@ impl Default for TtydConfig { } #[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] -pub struct GostcConfig { +pub struct EasyfrpConfig { pub enabled: bool, - pub addr: String, - #[serde(skip_serializing_if = "String::is_empty")] - pub key: String, - pub tls: bool, -} - -impl Default for GostcConfig { - fn default() -> Self { - Self { - enabled: false, - addr: String::new(), - key: String::new(), - tls: true, - } - } + pub control_urls: Vec, } #[typeshare] @@ -217,7 +203,7 @@ impl Default for FrpcConfig { #[serde(default)] pub struct ExtensionsConfig { pub ttyd: TtydConfig, - pub gostc: GostcConfig, + pub easyfrp: EasyfrpConfig, pub easytier: EasytierConfig, pub frpc: FrpcConfig, } @@ -239,10 +225,10 @@ pub struct TtydInfo { #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GostcInfo { +pub struct EasyfrpInfo { pub available: bool, pub status: ExtensionStatus, - pub config: GostcConfig, + pub config: EasyfrpConfig, } #[typeshare] @@ -265,7 +251,7 @@ pub struct FrpcInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExtensionsStatus { pub ttyd: TtydInfo, - pub gostc: GostcInfo, + pub easyfrp: EasyfrpInfo, pub easytier: EasytierInfo, pub frpc: FrpcInfo, } @@ -279,7 +265,17 @@ pub struct ExtensionLogs { #[cfg(test)] mod tests { - use super::{EasytierConfig, EasytierConfigMode}; + use super::{EasytierConfig, EasytierConfigMode, ExtensionsConfig}; + + #[test] + fn replacing_legacy_gostc_requires_new_easyfrp_configuration() { + let config: ExtensionsConfig = serde_json::from_str( + r#"{"gostc":{"enabled":true,"addr":"old.example.com","key":"old-key","tls":true}}"#, + ) + .unwrap(); + assert!(!config.easyfrp.enabled); + assert!(config.easyfrp.control_urls.is_empty()); + } #[test] fn legacy_easytier_config_defaults_to_quick_mode() { diff --git a/src/extensions/validation.rs b/src/extensions/validation.rs index 9da0c722..31180100 100644 --- a/src/extensions/validation.rs +++ b/src/extensions/validation.rs @@ -1,8 +1,8 @@ use toml_edit::DocumentMut; use super::types::{ - EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, FrpcConfig, - FrpcConfigMode, GostcConfig, + EasyfrpConfig, EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, + FrpcConfig, FrpcConfigMode, }; pub(crate) fn validate_extension_config( @@ -11,15 +11,36 @@ pub(crate) fn validate_extension_config( ) -> Result<(), String> { match id { ExtensionId::Ttyd => Ok(()), - ExtensionId::Gostc => validate_gostc_config(&config.gostc), + ExtensionId::Easyfrp => validate_easyfrp_config(&config.easyfrp), ExtensionId::Easytier => validate_easytier_config(&config.easytier), ExtensionId::Frpc => validate_frpc_config(&config.frpc), } } -pub(crate) fn validate_gostc_config(config: &GostcConfig) -> Result<(), String> { - require_non_empty(config.addr.trim(), "GOSTC server address is required")?; - require_non_empty(config.key.as_str(), "GOSTC client key is required") +pub(crate) fn validate_easyfrp_config(config: &EasyfrpConfig) -> Result<(), String> { + if config.control_urls.is_empty() { + return Err("EasyFrp control URL is required".to_string()); + } + for value in &config.control_urls { + let value = value.trim(); + if value.chars().any(char::is_whitespace) { + return Err("EasyFrp control URL must not contain whitespace".to_string()); + } + let url = + reqwest::Url::parse(value).map_err(|_| "EasyFrp control URL is invalid".to_string())?; + if !matches!(url.scheme(), "ws" | "wss" | "http" | "https" | "txt") + || url.host_str().is_none() + || url.path().trim_matches('/').is_empty() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err( + "EasyFrp requires a ws/wss/http/https/txt URL with a client key path".to_string(), + ); + } + } + Ok(()) } pub(crate) fn validate_easytier_config(config: &EasytierConfig) -> Result<(), String> { @@ -80,6 +101,36 @@ fn validate_full_toml(extension_name: &str, config: &str) -> Result<(), String> mod tests { use super::*; + #[test] + fn easyfrp_accepts_documented_control_protocols() { + for scheme in ["ws", "wss", "http", "https", "txt"] { + let config = EasyfrpConfig { + enabled: true, + control_urls: vec![format!("{scheme}://example.com/client-key")], + }; + assert!(validate_easyfrp_config(&config).is_ok()); + } + } + + #[test] + fn easyfrp_rejects_missing_keys_and_non_connection_arguments() { + for urls in [ + vec![], + vec![""], + vec!["--help"], + vec!["wss://example.com/"], + vec!["file:///tmp/config"], + vec!["wss://example.com/key with spaces"], + vec!["wss://example.com/key", "invalid"], + ] { + let config = EasyfrpConfig { + enabled: true, + control_urls: urls.into_iter().map(str::to_string).collect(), + }; + assert!(validate_easyfrp_config(&config).is_err()); + } + } + #[test] fn validates_easytier_full_configuration() { let mut config = EasytierConfig { diff --git a/src/web/handlers/extensions.rs b/src/web/handlers/extensions.rs index 6b4e9019..e798ca71 100644 --- a/src/web/handlers/extensions.rs +++ b/src/web/handlers/extensions.rs @@ -8,10 +8,10 @@ use typeshare::typeshare; use crate::error::{AppError, Result}; use crate::extensions::{ - validate_easytier_config, validate_extension_config, validate_frpc_config, - validate_gostc_config, EasytierConfig, EasytierConfigMode, EasytierInfo, ExtensionId, - ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType, FrpcConfig, FrpcConfigMode, - FrpcInfo, GostcConfig, GostcInfo, TtydConfig, TtydInfo, + validate_easyfrp_config, validate_easytier_config, validate_extension_config, + validate_frpc_config, EasyfrpConfig, EasyfrpInfo, EasytierConfig, EasytierConfigMode, + EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType, + FrpcConfig, FrpcConfigMode, FrpcInfo, TtydConfig, TtydInfo, }; use crate::state::AppState; @@ -29,10 +29,10 @@ pub async fn list_extensions(State(state): State>) -> Json, - pub addr: Option, - pub key: Option, - pub tls: Option, + pub control_urls: Option>, } #[typeshare] @@ -209,52 +207,46 @@ pub async fn update_ttyd_config( Ok(Json(new_config.extensions.ttyd.clone())) } -pub async fn update_gostc_config( +pub async fn update_easyfrp_config( State(state): State>, - Json(req): Json, -) -> Result> { + Json(req): Json, +) -> Result> { let current_config = state.config.get(); - let was_enabled = current_config.extensions.gostc.enabled; - let mut next_gostc = current_config.extensions.gostc.clone(); + let was_enabled = current_config.extensions.easyfrp.enabled; + let mut next_easyfrp = current_config.extensions.easyfrp.clone(); if let Some(enabled) = req.enabled { - next_gostc.enabled = enabled; + next_easyfrp.enabled = enabled; } - if let Some(ref addr) = req.addr { - next_gostc.addr = addr.clone(); - } - if let Some(ref key) = req.key { - next_gostc.key = key.clone(); - } - if let Some(tls) = req.tls { - next_gostc.tls = tls; + if let Some(ref urls) = req.control_urls { + next_easyfrp.control_urls = urls.iter().map(|url| url.trim().to_string()).collect(); } - if next_gostc.enabled { - bad_request(validate_gostc_config(&next_gostc))?; + if next_easyfrp.enabled { + bad_request(validate_easyfrp_config(&next_easyfrp))?; } state .config .update(|config| { - config.extensions.gostc = next_gostc.clone(); + config.extensions.easyfrp = next_easyfrp.clone(); }) .await?; let new_config = state.config.get(); - let is_enabled = new_config.extensions.gostc.enabled; + let is_enabled = new_config.extensions.easyfrp.enabled; if was_enabled && !is_enabled { - state.extensions.stop(ExtensionId::Gostc).await.ok(); - } else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Gostc) { + state.extensions.stop(ExtensionId::Easyfrp).await.ok(); + } else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Easyfrp) { state .extensions - .start(ExtensionId::Gostc, &new_config.extensions) + .start(ExtensionId::Easyfrp, &new_config.extensions) .await .ok(); } - Ok(Json(new_config.extensions.gostc.clone())) + Ok(Json(new_config.extensions.easyfrp.clone())) } pub async fn update_easytier_config( diff --git a/src/web/routes.rs b/src/web/routes.rs index 35d88b83..f7b81c8f 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -259,8 +259,8 @@ pub fn create_router(state: Arc) -> Router { patch(handlers::extensions::update_ttyd_config), ) .route( - "/extensions/gostc/config", - patch(handlers::extensions::update_gostc_config), + "/extensions/easyfrp/config", + patch(handlers::extensions::update_easyfrp_config), ) .route( "/extensions/easytier/config", diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 0ac22d27..23d13b06 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -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('/extensions/gostc/config', { + updateEasyfrp: (config: EasyfrpConfigUpdate) => + request('/extensions/easyfrp/config', { method: 'PATCH', body: JSON.stringify(config), }), diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 6594d71c..c916c2ae 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -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', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 0a3fc2f7..5c3e5e72 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -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 组网', diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index aa3387e3..df5f24f9 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -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 */ diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 2240822f..848b9be0 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -434,13 +434,13 @@ const extensions = ref(null) const extensionsLoading = ref(false) const extensionLogs = ref>({ ttyd: [], - gostc: [], + easyfrp: [], easytier: [], frpc: [], }) const showLogs = ref>({ 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 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, () => {
- {{ t('extensions.gostc.title') }} - {{ t('extensions.gostc.desc') }} + {{ t('extensions.easyfrp.title') }} + {{ t('extensions.easyfrp.desc') }}
- - {{ extensions?.gostc?.available ? t('extensions.available') : t('extensions.unavailable') }} + + {{ extensions?.easyfrp?.available ? t('extensions.available') : t('extensions.unavailable') }}
-
- {{ t('extensions.binaryNotFound', { path: isWindows ? 'gostc.exe' : '/usr/bin/gostc' }) }} +
+ {{ t('extensions.binaryNotFound', { path: isWindows ? 'easyfrpc.exe' : '/usr/bin/easyfrpc' }) }}