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

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