mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-28 08:20:49 +08:00
feat(extensions): 使用 EasyFrp 替换 gostc
将扩展配置、接口和设置页切换为 EasyFrp,支持按行填写多个客户端连接 URL,通过 easyfrpc control 启动并校验协议与密钥路径。 旧 gostc 配置不自动启用 EasyFrp,升级后需重新配置;启动日志不再输出命令参数,避免暴露连接信息。 完整 Docker 镜像包含 easyfrpc、easytier-core、easytier-cli 和 frpc,通过独立下载脚本及版本锁定文件校验下载摘要和二进制架构。
This commit is contained in:
@@ -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/
|
||||
|
||||
68
build/download-remote-tools.py
Normal file
68
build/download-remote-tools.py
Normal 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)
|
||||
@@ -118,7 +118,7 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " # Build for current platform and load locally"
|
||||
echo " $0 --platform linux/arm64 --load"
|
||||
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 ""
|
||||
echo " # Build and push single platform"
|
||||
@@ -184,21 +184,12 @@ get_tool_urls() {
|
||||
case "$platform" in
|
||||
"linux/amd64")
|
||||
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")
|
||||
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")
|
||||
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
|
||||
}
|
||||
@@ -231,22 +222,9 @@ download_tools() {
|
||||
fi
|
||||
|
||||
if [ "$INCLUDE_THIRD_PARTY" = true ]; then
|
||||
# gostc
|
||||
if [ ! -f "$staging/gostc" ]; then
|
||||
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
|
||||
python3 "$PROJECT_ROOT/build/download-remote-tools.py" \
|
||||
--platform "$platform" --output "$staging"
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
|
||||
74
build/remote-access.lock.json
Normal file
74
build/remote-access.lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use tokio::sync::RwLock;
|
||||
|
||||
use super::protected_config::ProtectedConfigFile;
|
||||
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;
|
||||
|
||||
const LOG_BUFFER_SIZE: usize = 200;
|
||||
@@ -82,8 +82,8 @@ impl ExtensionManager {
|
||||
fn is_enabled_for_config(id: ExtensionId, config: &ExtensionsConfig) -> 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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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<Self, Self::Err> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Arc<AppState>>) -> Json<Extensi
|
||||
status: mgr.status(ExtensionId::Ttyd).await,
|
||||
config: config.extensions.ttyd.clone(),
|
||||
},
|
||||
gostc: GostcInfo {
|
||||
available: mgr.check_available(ExtensionId::Gostc),
|
||||
status: mgr.status(ExtensionId::Gostc).await,
|
||||
config: config.extensions.gostc.clone(),
|
||||
easyfrp: EasyfrpInfo {
|
||||
available: mgr.check_available(ExtensionId::Easyfrp),
|
||||
status: mgr.status(ExtensionId::Easyfrp).await,
|
||||
config: config.extensions.easyfrp.clone(),
|
||||
},
|
||||
easytier: EasytierInfo {
|
||||
available: mgr.check_available(ExtensionId::Easytier),
|
||||
@@ -134,11 +134,9 @@ pub struct TtydConfigUpdate {
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GostcConfigUpdate {
|
||||
pub struct EasyfrpConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub addr: Option<String>,
|
||||
pub key: Option<String>,
|
||||
pub tls: Option<bool>,
|
||||
pub control_urls: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<Arc<AppState>>,
|
||||
Json(req): Json<GostcConfigUpdate>,
|
||||
) -> Result<Json<GostcConfig>> {
|
||||
Json(req): Json<EasyfrpConfigUpdate>,
|
||||
) -> Result<Json<EasyfrpConfig>> {
|
||||
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(
|
||||
|
||||
@@ -259,8 +259,8 @@ pub fn create_router(state: Arc<AppState>) -> 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",
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 组网',
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user