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 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/

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 " $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
}

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::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 {

View File

@@ -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,
};

View File

@@ -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",
}

View File

@@ -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",
}

View File

@@ -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() {

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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