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

@@ -52,7 +52,7 @@ RUN if [ -n "$APT_MIRROR" ]; then \
COPY --chmod=755 init.sh /init.sh COPY --chmod=755 init.sh /init.sh
# Copy binaries (these are placed by the build script) # 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 resources if they exist
COPY ventoy/ /etc/one-kvm/ventoy/ COPY ventoy/ /etc/one-kvm/ventoy/

View File

@@ -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('<H', data[18:20])[0]) != expected):
raise ValueError('executable architecture differs: ' + name)
path = staging / name
path.write_bytes(data)
path.chmod(0o755)
for spec in lock['tools'].values():
for name in spec['binaries']:
shutil.copy2(staging / name, output / name)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--platform', required=True, choices=('linux/amd64', 'linux/arm64', 'linux/arm/v7'))
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
install(args.platform, args.output)

View File

@@ -118,7 +118,7 @@ while [[ $# -gt 0 ]]; do
echo " # Build for current platform and load locally" echo " # Build for current platform and load locally"
echo " $0 --platform linux/arm64 --load" echo " $0 --platform linux/arm64 --load"
echo "" echo ""
echo " # Build full image (includes gostc + easytier)" echo " # Build full image (includes EasyFrp + EasyTier + frpc)"
echo " $0 --variant full --platform linux/arm64 --load" echo " $0 --variant full --platform linux/arm64 --load"
echo "" echo ""
echo " # Build and push single platform" echo " # Build and push single platform"
@@ -184,21 +184,12 @@ get_tool_urls() {
case "$platform" in case "$platform" in
"linux/amd64") "linux/amd64")
TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64" TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64"
GOSTC_URL="https://github.com/SianHH/gostc-open/releases/download/v2.0.9/gostc_linux_amd64_v1.tar.gz"
EASYTIER_URL="https://github.com/EasyTier/EasyTier/releases/download/v2.4.5/easytier-linux-x86_64-v2.4.5.zip"
EASYTIER_DIR="easytier-linux-x86_64"
;; ;;
"linux/arm64") "linux/arm64")
TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.aarch64" TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.aarch64"
GOSTC_URL="https://github.com/SianHH/gostc-open/releases/download/v2.0.9/gostc_linux_arm64_v8.0.tar.gz"
EASYTIER_URL="https://github.com/EasyTier/EasyTier/releases/download/v2.4.5/easytier-linux-aarch64-v2.4.5.zip"
EASYTIER_DIR="easytier-linux-aarch64"
;; ;;
"linux/arm/v7") "linux/arm/v7")
TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.armhf" TTYD_URL="https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.armhf"
GOSTC_URL="https://github.com/SianHH/gostc-open/releases/download/v2.0.9/gostc_linux_arm_7.tar.gz"
EASYTIER_URL="https://github.com/EasyTier/EasyTier/releases/download/v2.4.5/easytier-linux-armv7hf-v2.4.5.zip"
EASYTIER_DIR="easytier-linux-armv7hf"
;; ;;
esac esac
} }
@@ -231,22 +222,9 @@ download_tools() {
fi fi
if [ "$INCLUDE_THIRD_PARTY" = true ]; then if [ "$INCLUDE_THIRD_PARTY" = true ]; then
# gostc python3 "$PROJECT_ROOT/build/download-remote-tools.py" \
if [ ! -f "$staging/gostc" ]; then --platform "$platform" --output "$staging"
dl "$GOSTC_URL" /tmp/gostc.tar.gz
tar -xzf /tmp/gostc.tar.gz -C "$staging"
chmod +x "$staging/gostc"
rm /tmp/gostc.tar.gz
fi
# easytier
if [ ! -f "$staging/easytier-core" ]; then
dl "$EASYTIER_URL" /tmp/easytier.zip
unzip -o /tmp/easytier.zip -d /tmp/easytier
cp "/tmp/easytier/$EASYTIER_DIR/easytier-core" "$staging/easytier-core"
chmod +x "$staging/easytier-core"
rm -rf /tmp/easytier.zip /tmp/easytier
fi
fi fi
} }

View File

@@ -0,0 +1,74 @@
{
"tools": {
"easyfrp": {
"version": "v3.1.1",
"binaries": [
"easyfrpc"
],
"assets": {
"arm": {
"archive": "easyfrpc_linux_arm_7.tar.gz",
"url": "https://github.com/SianHH/easyfrp/releases/download/v3.1.1/easyfrpc_linux_arm_7.tar.gz",
"sha256": "98fa5c95a01295b59043d2869251a63249d286813b66544389f80d6fa3b07ae6"
},
"aarch64": {
"archive": "easyfrpc_linux_arm64_v8.0.tar.gz",
"url": "https://github.com/SianHH/easyfrp/releases/download/v3.1.1/easyfrpc_linux_arm64_v8.0.tar.gz",
"sha256": "cbd6c21fcd4d1921ee86879a4e90f1abffeb49324cbf8b31b1fc4581523b34d9"
},
"x86_64": {
"archive": "easyfrpc_linux_amd64_v1.tar.gz",
"url": "https://github.com/SianHH/easyfrp/releases/download/v3.1.1/easyfrpc_linux_amd64_v1.tar.gz",
"sha256": "e744618a57305c3a6a866cc2e08ca09da22dad1cd1a4aa5216ab10ebf7ba16ec"
}
}
},
"easytier": {
"version": "v2.6.4",
"binaries": [
"easytier-core",
"easytier-cli"
],
"assets": {
"arm": {
"archive": "easytier-linux-armv7hf-v2.6.4.zip",
"url": "https://github.com/EasyTier/EasyTier/releases/download/v2.6.4/easytier-linux-armv7hf-v2.6.4.zip",
"sha256": "af0186ce95ffbe90b0e9dc8df0e9a01563f59e94ea52212651950c34dc35ac37"
},
"aarch64": {
"archive": "easytier-linux-aarch64-v2.6.4.zip",
"url": "https://github.com/EasyTier/EasyTier/releases/download/v2.6.4/easytier-linux-aarch64-v2.6.4.zip",
"sha256": "f533ec25a7ea714e09f645615012200278058525795cc3bb690ff011aec1a70f"
},
"x86_64": {
"archive": "easytier-linux-x86_64-v2.6.4.zip",
"url": "https://github.com/EasyTier/EasyTier/releases/download/v2.6.4/easytier-linux-x86_64-v2.6.4.zip",
"sha256": "61b659eaedba658fa66fe47d17e1426cdd77e5d02fa15fed447bb4357c09dfd6"
}
}
},
"frp": {
"version": "v0.71.1",
"binaries": [
"frpc"
],
"assets": {
"arm": {
"archive": "frp_0.71.1_linux_arm_hf.tar.gz",
"url": "https://github.com/fatedier/frp/releases/download/v0.71.1/frp_0.71.1_linux_arm_hf.tar.gz",
"sha256": "a40116f15f2a05b7001f985f1f1ccb2570e2aa41329b06de76a82e71495786db"
},
"aarch64": {
"archive": "frp_0.71.1_linux_arm64.tar.gz",
"url": "https://github.com/fatedier/frp/releases/download/v0.71.1/frp_0.71.1_linux_arm64.tar.gz",
"sha256": "8edf590b0c396fa42f9c147121a76af83cfc2002b08f5aef0ea1008c43482019"
},
"x86_64": {
"archive": "frp_0.71.1_linux_amd64.tar.gz",
"url": "https://github.com/fatedier/frp/releases/download/v0.71.1/frp_0.71.1_linux_amd64.tar.gz",
"sha256": "1d9875210972a5d1a62b2dc4c48df45ab0af05d3ca1b5e494a014428259df803"
}
}
}
}
}

View File

@@ -9,7 +9,7 @@ use tokio::sync::RwLock;
use super::protected_config::ProtectedConfigFile; use super::protected_config::ProtectedConfigFile;
use super::types::*; use super::types::*;
use super::validation::{validate_easytier_config, validate_frpc_config, validate_gostc_config}; use super::validation::{validate_easyfrp_config, validate_easytier_config, validate_frpc_config};
use crate::events::EventBus; use crate::events::EventBus;
const LOG_BUFFER_SIZE: usize = 200; const LOG_BUFFER_SIZE: usize = 200;
@@ -82,8 +82,8 @@ impl ExtensionManager {
fn is_enabled_for_config(id: ExtensionId, config: &ExtensionsConfig) -> bool { fn is_enabled_for_config(id: ExtensionId, config: &ExtensionsConfig) -> bool {
match id { match id {
ExtensionId::Ttyd => config.ttyd.enabled, ExtensionId::Ttyd => config.ttyd.enabled,
ExtensionId::Gostc => { ExtensionId::Easyfrp => {
config.gostc.enabled && validate_gostc_config(&config.gostc).is_ok() config.easyfrp.enabled && validate_easyfrp_config(&config.easyfrp).is_ok()
} }
ExtensionId::Easytier => { ExtensionId::Easytier => {
config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok() config.easytier.enabled && validate_easytier_config(&config.easytier).is_ok()
@@ -144,12 +144,7 @@ impl ExtensionManager {
let launch = self.build_launch(id, config).await?; let launch = self.build_launch(id, config).await?;
tracing::info!( tracing::info!("Starting extension {}: {}", id, id.binary_path().display());
"Starting extension {}: {} {}",
id,
id.binary_path().display(),
launch.args.join(" ")
);
let mut child = Command::new(id.binary_path()) let mut child = Command::new(id.binary_path())
.args(&launch.args) .args(&launch.args)
@@ -272,17 +267,12 @@ impl ExtensionManager {
args args
} }
ExtensionId::Gostc => { ExtensionId::Easyfrp => {
let c = &config.gostc; let c = &config.easyfrp;
validate_gostc_config(c)?; validate_easyfrp_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()]);
let mut args = vec!["control".to_string()];
args.extend(c.control_urls.iter().map(|url| url.trim().to_string()));
args args
} }
@@ -580,6 +570,27 @@ impl ExtensionManager {
mod tests { mod tests {
use super::*; 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] #[tokio::test]
async fn easytier_launch_revalidates_full_configuration() { async fn easytier_launch_revalidates_full_configuration() {
let config = EasytierConfig { let config = EasytierConfig {

View File

@@ -11,6 +11,6 @@ pub use manager::TTYD_SOCKET_PATH;
pub use manager::TTYD_TCP_ADDR; pub use manager::TTYD_TCP_ADDR;
pub use types::*; pub use types::*;
pub(crate) use validation::{ pub(crate) use validation::{
validate_easytier_config, validate_extension_config, validate_frpc_config, validate_easyfrp_config, validate_easytier_config, validate_extension_config,
validate_gostc_config, validate_frpc_config,
}; };

View File

@@ -5,7 +5,7 @@ use super::ExtensionId;
pub fn default_binary_path(id: ExtensionId) -> &'static str { pub fn default_binary_path(id: ExtensionId) -> &'static str {
match id { match id {
ExtensionId::Ttyd => "/usr/bin/ttyd", ExtensionId::Ttyd => "/usr/bin/ttyd",
ExtensionId::Gostc => "/usr/bin/gostc", ExtensionId::Easyfrp => "/usr/bin/easyfrpc",
ExtensionId::Easytier => "/usr/bin/easytier-core", ExtensionId::Easytier => "/usr/bin/easytier-core",
ExtensionId::Frpc => "/usr/bin/frpc", ExtensionId::Frpc => "/usr/bin/frpc",
} }

View File

@@ -5,7 +5,7 @@ use super::ExtensionId;
pub fn default_binary_path(id: ExtensionId) -> &'static str { pub fn default_binary_path(id: ExtensionId) -> &'static str {
match id { match id {
ExtensionId::Ttyd => "ttyd.win32.exe", ExtensionId::Ttyd => "ttyd.win32.exe",
ExtensionId::Gostc => "gostc.exe", ExtensionId::Easyfrp => "easyfrpc.exe",
ExtensionId::Easytier => "easytier-core.exe", ExtensionId::Easytier => "easytier-core.exe",
ExtensionId::Frpc => "frpc.exe", ExtensionId::Frpc => "frpc.exe",
} }

View File

@@ -8,7 +8,7 @@ use super::software;
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ExtensionId { pub enum ExtensionId {
Ttyd, Ttyd,
Gostc, Easyfrp,
Easytier, Easytier,
Frpc, Frpc,
} }
@@ -19,7 +19,7 @@ impl ExtensionId {
} }
pub fn all() -> &'static [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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Ttyd => write!(f, "ttyd"), Self::Ttyd => write!(f, "ttyd"),
Self::Gostc => write!(f, "gostc"), Self::Easyfrp => write!(f, "easyfrp"),
Self::Easytier => write!(f, "easytier"), Self::Easytier => write!(f, "easytier"),
Self::Frpc => write!(f, "frpc"), Self::Frpc => write!(f, "frpc"),
} }
@@ -40,7 +40,7 @@ impl std::str::FromStr for ExtensionId {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"ttyd" => Ok(Self::Ttyd), "ttyd" => Ok(Self::Ttyd),
"gostc" => Ok(Self::Gostc), "easyfrp" => Ok(Self::Easyfrp),
"easytier" => Ok(Self::Easytier), "easytier" => Ok(Self::Easytier),
"frpc" => Ok(Self::Frpc), "frpc" => Ok(Self::Frpc),
_ => Err(format!("Unknown extension: {}", s)), _ => Err(format!("Unknown extension: {}", s)),
@@ -81,25 +81,11 @@ impl Default for TtydConfig {
} }
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)] #[serde(default)]
pub struct GostcConfig { pub struct EasyfrpConfig {
pub enabled: bool, pub enabled: bool,
pub addr: String, pub control_urls: Vec<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,
}
}
} }
#[typeshare] #[typeshare]
@@ -217,7 +203,7 @@ impl Default for FrpcConfig {
#[serde(default)] #[serde(default)]
pub struct ExtensionsConfig { pub struct ExtensionsConfig {
pub ttyd: TtydConfig, pub ttyd: TtydConfig,
pub gostc: GostcConfig, pub easyfrp: EasyfrpConfig,
pub easytier: EasytierConfig, pub easytier: EasytierConfig,
pub frpc: FrpcConfig, pub frpc: FrpcConfig,
} }
@@ -239,10 +225,10 @@ pub struct TtydInfo {
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GostcInfo { pub struct EasyfrpInfo {
pub available: bool, pub available: bool,
pub status: ExtensionStatus, pub status: ExtensionStatus,
pub config: GostcConfig, pub config: EasyfrpConfig,
} }
#[typeshare] #[typeshare]
@@ -265,7 +251,7 @@ pub struct FrpcInfo {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionsStatus { pub struct ExtensionsStatus {
pub ttyd: TtydInfo, pub ttyd: TtydInfo,
pub gostc: GostcInfo, pub easyfrp: EasyfrpInfo,
pub easytier: EasytierInfo, pub easytier: EasytierInfo,
pub frpc: FrpcInfo, pub frpc: FrpcInfo,
} }
@@ -279,7 +265,17 @@ pub struct ExtensionLogs {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn legacy_easytier_config_defaults_to_quick_mode() { fn legacy_easytier_config_defaults_to_quick_mode() {

View File

@@ -1,8 +1,8 @@
use toml_edit::DocumentMut; use toml_edit::DocumentMut;
use super::types::{ use super::types::{
EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType, FrpcConfig, EasyfrpConfig, EasytierConfig, EasytierConfigMode, ExtensionId, ExtensionsConfig, FrpProxyType,
FrpcConfigMode, GostcConfig, FrpcConfig, FrpcConfigMode,
}; };
pub(crate) fn validate_extension_config( pub(crate) fn validate_extension_config(
@@ -11,15 +11,36 @@ pub(crate) fn validate_extension_config(
) -> Result<(), String> { ) -> Result<(), String> {
match id { match id {
ExtensionId::Ttyd => Ok(()), 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::Easytier => validate_easytier_config(&config.easytier),
ExtensionId::Frpc => validate_frpc_config(&config.frpc), ExtensionId::Frpc => validate_frpc_config(&config.frpc),
} }
} }
pub(crate) fn validate_gostc_config(config: &GostcConfig) -> Result<(), String> { pub(crate) fn validate_easyfrp_config(config: &EasyfrpConfig) -> Result<(), String> {
require_non_empty(config.addr.trim(), "GOSTC server address is required")?; if config.control_urls.is_empty() {
require_non_empty(config.key.as_str(), "GOSTC client key is required") 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> { 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 { mod tests {
use super::*; 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] #[test]
fn validates_easytier_full_configuration() { fn validates_easytier_full_configuration() {
let mut config = EasytierConfig { let mut config = EasytierConfig {

View File

@@ -8,10 +8,10 @@ use typeshare::typeshare;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::extensions::{ use crate::extensions::{
validate_easytier_config, validate_extension_config, validate_frpc_config, validate_easyfrp_config, validate_easytier_config, validate_extension_config,
validate_gostc_config, EasytierConfig, EasytierConfigMode, EasytierInfo, ExtensionId, validate_frpc_config, EasyfrpConfig, EasyfrpInfo, EasytierConfig, EasytierConfigMode,
ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType, FrpcConfig, FrpcConfigMode, EasytierInfo, ExtensionId, ExtensionInfo, ExtensionLogs, ExtensionsStatus, FrpProxyType,
FrpcInfo, GostcConfig, GostcInfo, TtydConfig, TtydInfo, FrpcConfig, FrpcConfigMode, FrpcInfo, TtydConfig, TtydInfo,
}; };
use crate::state::AppState; use crate::state::AppState;
@@ -29,10 +29,10 @@ pub async fn list_extensions(State(state): State<Arc<AppState>>) -> Json<Extensi
status: mgr.status(ExtensionId::Ttyd).await, status: mgr.status(ExtensionId::Ttyd).await,
config: config.extensions.ttyd.clone(), config: config.extensions.ttyd.clone(),
}, },
gostc: GostcInfo { easyfrp: EasyfrpInfo {
available: mgr.check_available(ExtensionId::Gostc), available: mgr.check_available(ExtensionId::Easyfrp),
status: mgr.status(ExtensionId::Gostc).await, status: mgr.status(ExtensionId::Easyfrp).await,
config: config.extensions.gostc.clone(), config: config.extensions.easyfrp.clone(),
}, },
easytier: EasytierInfo { easytier: EasytierInfo {
available: mgr.check_available(ExtensionId::Easytier), available: mgr.check_available(ExtensionId::Easytier),
@@ -134,11 +134,9 @@ pub struct TtydConfigUpdate {
#[typeshare] #[typeshare]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct GostcConfigUpdate { pub struct EasyfrpConfigUpdate {
pub enabled: Option<bool>, pub enabled: Option<bool>,
pub addr: Option<String>, pub control_urls: Option<Vec<String>>,
pub key: Option<String>,
pub tls: Option<bool>,
} }
#[typeshare] #[typeshare]
@@ -209,52 +207,46 @@ pub async fn update_ttyd_config(
Ok(Json(new_config.extensions.ttyd.clone())) Ok(Json(new_config.extensions.ttyd.clone()))
} }
pub async fn update_gostc_config( pub async fn update_easyfrp_config(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<GostcConfigUpdate>, Json(req): Json<EasyfrpConfigUpdate>,
) -> Result<Json<GostcConfig>> { ) -> Result<Json<EasyfrpConfig>> {
let current_config = state.config.get(); let current_config = state.config.get();
let was_enabled = current_config.extensions.gostc.enabled; let was_enabled = current_config.extensions.easyfrp.enabled;
let mut next_gostc = current_config.extensions.gostc.clone(); let mut next_easyfrp = current_config.extensions.easyfrp.clone();
if let Some(enabled) = req.enabled { if let Some(enabled) = req.enabled {
next_gostc.enabled = enabled; next_easyfrp.enabled = enabled;
} }
if let Some(ref addr) = req.addr { if let Some(ref urls) = req.control_urls {
next_gostc.addr = addr.clone(); next_easyfrp.control_urls = urls.iter().map(|url| url.trim().to_string()).collect();
}
if let Some(ref key) = req.key {
next_gostc.key = key.clone();
}
if let Some(tls) = req.tls {
next_gostc.tls = tls;
} }
if next_gostc.enabled { if next_easyfrp.enabled {
bad_request(validate_gostc_config(&next_gostc))?; bad_request(validate_easyfrp_config(&next_easyfrp))?;
} }
state state
.config .config
.update(|config| { .update(|config| {
config.extensions.gostc = next_gostc.clone(); config.extensions.easyfrp = next_easyfrp.clone();
}) })
.await?; .await?;
let new_config = state.config.get(); 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 { if was_enabled && !is_enabled {
state.extensions.stop(ExtensionId::Gostc).await.ok(); state.extensions.stop(ExtensionId::Easyfrp).await.ok();
} else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Gostc) { } else if !was_enabled && is_enabled && state.extensions.check_available(ExtensionId::Easyfrp) {
state state
.extensions .extensions
.start(ExtensionId::Gostc, &new_config.extensions) .start(ExtensionId::Easyfrp, &new_config.extensions)
.await .await
.ok(); .ok();
} }
Ok(Json(new_config.extensions.gostc.clone())) Ok(Json(new_config.extensions.easyfrp.clone()))
} }
pub async fn update_easytier_config( pub async fn update_easytier_config(

View File

@@ -259,8 +259,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
patch(handlers::extensions::update_ttyd_config), patch(handlers::extensions::update_ttyd_config),
) )
.route( .route(
"/extensions/gostc/config", "/extensions/easyfrp/config",
patch(handlers::extensions::update_gostc_config), patch(handlers::extensions::update_easyfrp_config),
) )
.route( .route(
"/extensions/easytier/config", "/extensions/easytier/config",

View File

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

View File

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

View File

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

View File

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

View File

@@ -434,13 +434,13 @@ const extensions = ref<ExtensionsStatus | null>(null)
const extensionsLoading = ref(false) const extensionsLoading = ref(false)
const extensionLogs = ref<Record<string, string[]>>({ const extensionLogs = ref<Record<string, string[]>>({
ttyd: [], ttyd: [],
gostc: [], easyfrp: [],
easytier: [], easytier: [],
frpc: [], frpc: [],
}) })
const showLogs = ref<Record<string, boolean>>({ const showLogs = ref<Record<string, boolean>>({
ttyd: false, ttyd: false,
gostc: false, easyfrp: false,
easytier: false, easytier: false,
frpc: false, frpc: false,
}) })
@@ -449,7 +449,7 @@ const showTerminalDialog = ref(false)
const extConfig = ref({ const extConfig = ref({
ttyd: { enabled: false, shell: '/bin/bash' }, ttyd: { enabled: false, shell: '/bin/bash' },
gostc: { enabled: false, addr: '', key: '', tls: true }, easyfrp: { enabled: false, control_urls: [] as string[] },
easytier: { easytier: {
enabled: false, enabled: false,
config_mode: EasytierConfigMode.Quick, config_mode: EasytierConfigMode.Quick,
@@ -477,9 +477,27 @@ const extConfig = ref({
}, },
}) })
const gostcValidationMessage = computed(() => { const easyfrpUrlsText = computed({
if (!extConfig.value.gostc.addr?.trim()) return t('extensions.gostc.addrRequired') get: () => extConfig.value.easyfrp.control_urls.join('\n'),
if (!extConfig.value.gostc.key) return t('extensions.gostc.keyRequired') 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 '' return ''
}) })
@@ -1698,7 +1716,7 @@ async function loadExtensions() {
enabled: ttyd.enabled, enabled: ttyd.enabled,
shell: ttyd.shell, shell: ttyd.shell,
} }
extConfig.value.gostc = { ...extensions.value.gostc.config } extConfig.value.easyfrp = { ...extensions.value.easyfrp.config }
const easytier = extensions.value.easytier.config const easytier = extensions.value.easytier.config
extConfig.value.easytier = { extConfig.value.easytier = {
enabled: easytier.enabled, 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'> type ValidatedExtensionConfigId = Exclude<ExtensionConfigId, 'ttyd'>
async function startExtension(id: ExtensionConfigId) { async function startExtension(id: ExtensionConfigId) {
@@ -1774,8 +1792,8 @@ async function saveExtensionConfig(id: ExtensionConfigId) {
try { try {
if (id === 'ttyd') { if (id === 'ttyd') {
await extensionsApi.updateTtyd(extConfig.value.ttyd) await extensionsApi.updateTtyd(extConfig.value.ttyd)
} else if (id === 'gostc') { } else if (id === 'easyfrp') {
await extensionsApi.updateGostc(extConfig.value.gostc) await extensionsApi.updateEasyfrp(extConfig.value.easyfrp)
} else if (id === 'easytier') { } else if (id === 'easytier') {
await extensionsApi.updateEasytier(extConfig.value.easytier) await extensionsApi.updateEasytier(extConfig.value.easytier)
} else if (id === 'frpc') { } else if (id === 'frpc') {
@@ -2052,8 +2070,8 @@ function showValidationError(message: string): boolean {
function validateExtensionConfig(id: ValidatedExtensionConfigId): boolean { function validateExtensionConfig(id: ValidatedExtensionConfigId): boolean {
let message = '' let message = ''
if (id === 'gostc') { if (id === 'easyfrp') {
message = gostcValidationMessage.value message = easyfrpValidationMessage.value
} else if (id === 'easytier') { } else if (id === 'easytier') {
message = easytierValidationMessage.value message = easytierValidationMessage.value
} else { } else {
@@ -4430,31 +4448,31 @@ watch(isWindows, () => {
<CardHeader> <CardHeader>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="space-y-1.5"> <div class="space-y-1.5">
<CardTitle>{{ t('extensions.gostc.title') }}</CardTitle> <CardTitle>{{ t('extensions.easyfrp.title') }}</CardTitle>
<CardDescription>{{ t('extensions.gostc.desc') }}</CardDescription> <CardDescription>{{ t('extensions.easyfrp.desc') }}</CardDescription>
</div> </div>
<Badge :variant="extensions?.gostc?.available ? 'default' : 'destructive'"> <Badge :variant="extensions?.easyfrp?.available ? 'default' : 'destructive'">
{{ extensions?.gostc?.available ? t('extensions.available') : t('extensions.unavailable') }} {{ extensions?.easyfrp?.available ? t('extensions.available') : t('extensions.unavailable') }}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div v-if="!extensions?.gostc?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md"> <div v-if="!extensions?.easyfrp?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
{{ t('extensions.binaryNotFound', { path: isWindows ? 'gostc.exe' : '/usr/bin/gostc' }) }} {{ t('extensions.binaryNotFound', { path: isWindows ? 'easyfrpc.exe' : '/usr/bin/easyfrpc' }) }}
</div> </div>
<template v-else> <template v-else>
<!-- Status and controls --> <!-- Status and controls -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div :class="['size-2 rounded-full', getExtStatusClass(extensions?.gostc?.status)]" /> <div :class="['size-2 rounded-full', getExtStatusClass(extensions?.easyfrp?.status)]" />
<span class="text-sm">{{ getExtStatusText(extensions?.gostc?.status) }}</span> <span class="text-sm">{{ getExtStatusText(extensions?.easyfrp?.status) }}</span>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
v-if="!isExtRunning(extensions?.gostc?.status)" v-if="!isExtRunning(extensions?.easyfrp?.status)"
size="sm" size="sm"
@click="startExtension('gostc')" @click="startExtension('easyfrp')"
:disabled="extensionsLoading || !!gostcValidationMessage" :disabled="extensionsLoading || !!easyfrpValidationMessage"
> >
<Play class="size-4 mr-1" /> <Play class="size-4 mr-1" />
{{ t('extensions.start') }} {{ t('extensions.start') }}
@@ -4463,7 +4481,7 @@ watch(isWindows, () => {
v-else v-else
size="sm" size="sm"
variant="outline" variant="outline"
@click="stopExtension('gostc')" @click="stopExtension('easyfrp')"
:disabled="extensionsLoading" :disabled="extensionsLoading"
> >
<Square class="size-4 mr-1" /> <Square class="size-4 mr-1" />
@@ -4476,36 +4494,24 @@ watch(isWindows, () => {
<div class="grid gap-4"> <div class="grid gap-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label>{{ t('extensions.autoStart') }}</Label> <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>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center"> <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"> <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)" /> <Textarea v-model="easyfrpUrlsText" :rows="3" autocomplete="off" :spellcheck="false" :placeholder="t('extensions.easyfrp.urlsPlaceholder')" :disabled="isExtRunning(extensions?.easyfrp?.status)" />
<p v-if="extConfig.gostc.enabled && !extConfig.gostc.addr?.trim()" class="text-xs text-destructive">{{ t('extensions.gostc.addrRequired') }}</p> <p class="text-xs text-muted-foreground">{{ t('extensions.easyfrp.urlsHint') }}</p>
</div> <p v-if="extConfig.easyfrp.enabled && easyfrpValidationMessage" class="text-xs text-destructive">{{ easyfrpValidationMessage }}</p>
</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)" />
</div> </div>
</div> </div>
</div> </div>
<!-- Logs --> <!-- Logs -->
<div class="space-y-2"> <div class="space-y-2">
<Collapsible v-model:open="showLogs.gostc" @update:open="open => open && refreshExtensionLogs('gostc')"> <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.gostc ? 'rotate-90' : '']" />{{ t('extensions.viewLogs') }}</Button></CollapsibleTrigger> <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"> <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> <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('gostc')"> <Button variant="ghost" size="sm" @click="refreshExtensionLogs('easyfrp')">
<RefreshCw class="size-3 mr-1" /> <RefreshCw class="size-3 mr-1" />
{{ t('common.refresh') }} {{ t('common.refresh') }}
</Button> </Button>
@@ -4514,8 +4520,8 @@ watch(isWindows, () => {
</div> </div>
</template> </template>
</CardContent> </CardContent>
<CardFooter v-if="extensions?.gostc?.available" class="border-t pt-4 justify-end"> <CardFooter v-if="extensions?.easyfrp?.available" class="border-t pt-4 justify-end">
<Button :disabled="loading || isExtRunning(extensions?.gostc?.status)" @click="saveExtensionConfig('gostc')"> <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') }} <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> </Button>
</CardFooter> </CardFooter>